Database changes to make it better practise
This commit is contained in:
@@ -3,6 +3,8 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -13,6 +15,8 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
||||
await dataSource.getRepository(MessageEntity).clear();
|
||||
await dataSource.getRepository(UserEntity).clear();
|
||||
await dataSource.getRepository(RoomEntity).clear();
|
||||
await dataSource.getRepository(RoomChannelEntity).clear();
|
||||
await dataSource.getRepository(RoomMemberEntity).clear();
|
||||
await dataSource.getRepository(ReactionEntity).clear();
|
||||
await dataSource.getRepository(BanEntity).clear();
|
||||
await dataSource.getRepository(AttachmentEntity).clear();
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
||||
import {
|
||||
RoomChannelEntity,
|
||||
RoomEntity,
|
||||
RoomMemberEntity,
|
||||
MessageEntity
|
||||
} from '../../../entities';
|
||||
import { DeleteRoomCommand } from '../../types';
|
||||
|
||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const { roomId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(RoomChannelEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomMemberEntity).delete({ roomId });
|
||||
await manager.getRepository(RoomEntity).delete({ id: roomId });
|
||||
await manager.getRepository(MessageEntity).delete({ roomId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { SaveMessageCommand } from '../../types';
|
||||
|
||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { message } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
reactions: JSON.stringify(message.reactions ?? []),
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null
|
||||
});
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const entity = repo.create({
|
||||
id: message.id,
|
||||
roomId: message.roomId,
|
||||
channelId: message.channelId ?? null,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt ?? null,
|
||||
isDeleted: message.isDeleted ? 1 : 0,
|
||||
replyToId: message.replyToId ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await repo.save(entity);
|
||||
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { SaveRoomCommand } from '../../types';
|
||||
|
||||
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { room } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
||||
members: room.members != null ? JSON.stringify(room.members) : null,
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
});
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const entity = repo.create({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description ?? null,
|
||||
topic: room.topic ?? null,
|
||||
hostId: room.hostId,
|
||||
password: room.password ?? null,
|
||||
hasPassword: room.hasPassword ? 1 : 0,
|
||||
isPrivate: room.isPrivate ? 1 : 0,
|
||||
createdAt: room.createdAt,
|
||||
userCount: room.userCount ?? 0,
|
||||
maxUsers: room.maxUsers ?? null,
|
||||
icon: room.icon ?? null,
|
||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
||||
sourceId: room.sourceId ?? null,
|
||||
sourceName: room.sourceName ?? null,
|
||||
sourceUrl: room.sourceUrl ?? null
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await repo.save(entity);
|
||||
await replaceRoomRelations(manager, room.id, {
|
||||
channels: room.channels ?? [],
|
||||
members: room.members ?? []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { replaceMessageReactions } from '../../relations';
|
||||
import { UpdateMessageCommand } from '../../types';
|
||||
|
||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const { messageId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(MessageEntity);
|
||||
const existing = await repo.findOne({ where: { id: messageId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
if (updates.channelId !== undefined)
|
||||
existing.channelId = updates.channelId ?? null;
|
||||
if (updates.channelId !== undefined)
|
||||
existing.channelId = updates.channelId ?? null;
|
||||
|
||||
if (updates.senderId !== undefined)
|
||||
existing.senderId = updates.senderId;
|
||||
if (updates.senderId !== undefined)
|
||||
existing.senderId = updates.senderId;
|
||||
|
||||
if (updates.senderName !== undefined)
|
||||
existing.senderName = updates.senderName;
|
||||
if (updates.senderName !== undefined)
|
||||
existing.senderName = updates.senderName;
|
||||
|
||||
if (updates.content !== undefined)
|
||||
existing.content = updates.content;
|
||||
if (updates.content !== undefined)
|
||||
existing.content = updates.content;
|
||||
|
||||
if (updates.timestamp !== undefined)
|
||||
existing.timestamp = updates.timestamp;
|
||||
if (updates.timestamp !== undefined)
|
||||
existing.timestamp = updates.timestamp;
|
||||
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
if (updates.editedAt !== undefined)
|
||||
existing.editedAt = updates.editedAt ?? null;
|
||||
|
||||
if (updates.reactions !== undefined)
|
||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
|
||||
if (updates.isDeleted !== undefined)
|
||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||
if (updates.replyToId !== undefined)
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
|
||||
if (updates.replyToId !== undefined)
|
||||
existing.replyToId = updates.replyToId ?? null;
|
||||
await repo.save(existing);
|
||||
|
||||
await repo.save(existing);
|
||||
if (updates.reactions !== undefined) {
|
||||
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { replaceRoomRelations } from '../../relations';
|
||||
import { UpdateRoomCommand } from '../../types';
|
||||
import {
|
||||
applyUpdates,
|
||||
@@ -12,20 +13,26 @@ const ROOM_TRANSFORMS: TransformMap = {
|
||||
hasPassword: boolToInt,
|
||||
isPrivate: boolToInt,
|
||||
userCount: (val) => (val ?? 0),
|
||||
permissions: jsonOrNull,
|
||||
channels: jsonOrNull,
|
||||
members: jsonOrNull
|
||||
permissions: jsonOrNull
|
||||
};
|
||||
|
||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const { roomId, updates } = command.payload;
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(RoomEntity);
|
||||
const existing = await repo.findOne({ where: { id: roomId } });
|
||||
|
||||
if (!existing)
|
||||
return;
|
||||
if (!existing)
|
||||
return;
|
||||
|
||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
const { channels, members, ...entityUpdates } = updates;
|
||||
|
||||
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
|
||||
await repo.save(existing);
|
||||
await replaceRoomRelations(manager, roomId, {
|
||||
channels,
|
||||
members
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,14 @@ import { RoomEntity } from '../entities/RoomEntity';
|
||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||
import { BanEntity } from '../entities/BanEntity';
|
||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||
import {
|
||||
ReactionPayload,
|
||||
RoomPayload
|
||||
} from './types';
|
||||
|
||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
|
||||
export function rowToMessage(row: MessageEntity) {
|
||||
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||
const isDeleted = !!row.isDeleted;
|
||||
|
||||
return {
|
||||
@@ -24,7 +28,7 @@ export function rowToMessage(row: MessageEntity) {
|
||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||
timestamp: row.timestamp,
|
||||
editedAt: row.editedAt ?? undefined,
|
||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
||||
reactions: isDeleted ? [] : reactions,
|
||||
isDeleted,
|
||||
replyToId: row.replyToId ?? undefined
|
||||
};
|
||||
@@ -49,7 +53,10 @@ export function rowToUser(row: UserEntity) {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToRoom(row: RoomEntity) {
|
||||
export function rowToRoom(
|
||||
row: RoomEntity,
|
||||
relations: Pick<RoomPayload, 'channels' | 'members'> = { channels: [], members: [] }
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -65,8 +72,8 @@ export function rowToRoom(row: RoomEntity) {
|
||||
icon: row.icon ?? undefined,
|
||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
||||
members: row.members ? JSON.parse(row.members) : undefined,
|
||||
channels: relations.channels ?? [],
|
||||
members: relations.members ?? [],
|
||||
sourceId: row.sourceId ?? undefined,
|
||||
sourceName: row.sourceName ?? undefined,
|
||||
sourceUrl: row.sourceUrl ?? undefined
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const rows = await repo.find();
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToRoom);
|
||||
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessageByIdQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
||||
|
||||
return row ? rowToMessage(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DataSource, MoreThan } from 'typeorm';
|
||||
import { MessageEntity } from '../../../entities';
|
||||
import { GetMessagesSinceQuery } from '../../types';
|
||||
import { rowToMessage } from '../../mappers';
|
||||
import { loadMessageReactionsMap } from '../../relations';
|
||||
|
||||
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(MessageEntity);
|
||||
@@ -13,6 +14,7 @@ export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataS
|
||||
},
|
||||
order: { timestamp: 'ASC' }
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToMessage);
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { RoomEntity } from '../../../entities';
|
||||
import { GetRoomQuery } from '../../types';
|
||||
import { rowToRoom } from '../../mappers';
|
||||
import { loadRoomRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(RoomEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
||||
|
||||
return row ? rowToRoom(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToRoom(row, relationsByRoomId.get(row.id));
|
||||
}
|
||||
|
||||
426
electron/cqrs/relations.ts
Normal file
426
electron/cqrs/relations.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
In
|
||||
} from 'typeorm';
|
||||
import {
|
||||
ReactionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity
|
||||
} from '../entities';
|
||||
import { ReactionPayload } from './types';
|
||||
|
||||
type ChannelType = 'text' | 'voice';
|
||||
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
export interface RoomChannelRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface RoomMemberRecord {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: RoomMemberRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
|
||||
interface RoomRelationRecord {
|
||||
channels: RoomChannelRecord[];
|
||||
members: RoomMemberRecord[];
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ChannelType, name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||
return member.oderId?.trim() || member.id?.trim() || '';
|
||||
}
|
||||
|
||||
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<RoomMemberRecord>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||
? value
|
||||
: 'member';
|
||||
}
|
||||
|
||||
function mergeRoomMemberRole(
|
||||
existingRole: RoomMemberRole,
|
||||
incomingRole: RoomMemberRole,
|
||||
preferIncoming: boolean
|
||||
): RoomMemberRole {
|
||||
if (existingRole === incomingRole) {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||
return incomingRole;
|
||||
}
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0) {
|
||||
return displayNameCompare;
|
||||
}
|
||||
|
||||
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||
}
|
||||
|
||||
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
|
||||
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
|
||||
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
|
||||
const normalizedKey = normalizedOderId || normalizedId;
|
||||
|
||||
if (!normalizedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
|
||||
? rawMember['lastSeenAt']
|
||||
: isFiniteNumber(rawMember['joinedAt'])
|
||||
? rawMember['joinedAt']
|
||||
: now;
|
||||
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
|
||||
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
|
||||
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
|
||||
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
|
||||
|
||||
const member: RoomMemberRecord = {
|
||||
id: normalizedId || normalizedKey,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
role: normalizeRoomMemberRole(rawMember['role']),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
||||
if (!existingMember) {
|
||||
return incomingMember;
|
||||
}
|
||||
|
||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
|
||||
return {
|
||||
id: existingMember.id || incomingMember.id,
|
||||
oderId: incomingMember.oderId || existingMember.oderId,
|
||||
username: preferIncoming
|
||||
? (incomingMember.username || existingMember.username)
|
||||
: (existingMember.username || incomingMember.username),
|
||||
displayName: preferIncoming
|
||||
? (incomingMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || incomingMember.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
||||
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
|
||||
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
|
||||
if (!Array.isArray(rawReactions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const reactions: ReactionPayload[] = [];
|
||||
|
||||
for (const rawReaction of rawReactions) {
|
||||
if (!rawReaction || typeof rawReaction !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reaction = rawReaction as Record<string, unknown>;
|
||||
const emoji = typeof reaction['emoji'] === 'string' ? reaction['emoji'] : '';
|
||||
const userId = typeof reaction['userId'] === 'string' ? reaction['userId'] : '';
|
||||
const dedupeKey = `${userId}:${emoji}`;
|
||||
|
||||
if (!emoji || seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(dedupeKey);
|
||||
reactions.push({
|
||||
id: typeof reaction['id'] === 'string' && reaction['id'].trim() ? reaction['id'] : `${messageId}:${dedupeKey}`,
|
||||
messageId,
|
||||
oderId: typeof reaction['oderId'] === 'string' ? reaction['oderId'] : '',
|
||||
userId,
|
||||
emoji,
|
||||
timestamp: isFiniteNumber(reaction['timestamp']) ? reaction['timestamp'] : 0
|
||||
});
|
||||
}
|
||||
|
||||
return reactions;
|
||||
}
|
||||
|
||||
export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[] {
|
||||
if (!Array.isArray(rawChannels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: RoomChannelRecord[] = [];
|
||||
|
||||
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channel = rawChannel as Record<string, unknown>;
|
||||
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
|
||||
if (!Array.isArray(rawMembers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const membersByKey = new Map<string, RoomMemberRecord>();
|
||||
|
||||
for (const rawMember of rawMembers) {
|
||||
if (!rawMember || typeof rawMember !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
|
||||
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = memberKey(member);
|
||||
|
||||
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
|
||||
}
|
||||
|
||||
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
export async function replaceMessageReactions(
|
||||
manager: EntityManager,
|
||||
messageId: string,
|
||||
rawReactions: unknown
|
||||
): Promise<void> {
|
||||
const repo = manager.getRepository(ReactionEntity);
|
||||
|
||||
await repo.delete({ messageId });
|
||||
|
||||
const reactions = normalizeReactionPayloads(rawReactions, messageId);
|
||||
|
||||
if (reactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.insert(
|
||||
reactions.map((reaction) => ({
|
||||
id: reaction.id,
|
||||
messageId,
|
||||
oderId: reaction.oderId || null,
|
||||
userId: reaction.userId || null,
|
||||
emoji: reaction.emoji,
|
||||
timestamp: reaction.timestamp
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadMessageReactionsMap(
|
||||
dataSource: DataSource,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Map<string, ReactionPayload[]>> {
|
||||
const groupedReactions = new Map<string, ReactionPayload[]>();
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
return groupedReactions;
|
||||
}
|
||||
|
||||
const rows = await dataSource.getRepository(ReactionEntity).find({
|
||||
where: { messageId: In([...messageIds]) }
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const reactions = groupedReactions.get(row.messageId) ?? [];
|
||||
|
||||
reactions.push({
|
||||
id: row.id,
|
||||
messageId: row.messageId,
|
||||
oderId: row.oderId ?? '',
|
||||
userId: row.userId ?? '',
|
||||
emoji: row.emoji,
|
||||
timestamp: row.timestamp
|
||||
});
|
||||
groupedReactions.set(row.messageId, reactions);
|
||||
}
|
||||
|
||||
for (const reactions of groupedReactions.values()) {
|
||||
reactions.sort((first, second) => first.timestamp - second.timestamp);
|
||||
}
|
||||
|
||||
return groupedReactions;
|
||||
}
|
||||
|
||||
export async function replaceRoomRelations(
|
||||
manager: EntityManager,
|
||||
roomId: string,
|
||||
options: { channels?: unknown; members?: unknown }
|
||||
): Promise<void> {
|
||||
if (options.channels !== undefined) {
|
||||
const channelRepo = manager.getRepository(RoomChannelEntity);
|
||||
const channels = normalizeRoomChannels(options.channels);
|
||||
|
||||
await channelRepo.delete({ roomId });
|
||||
|
||||
if (channels.length > 0) {
|
||||
await channelRepo.insert(
|
||||
channels.map((channel) => ({
|
||||
roomId,
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
position: channel.position
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.members !== undefined) {
|
||||
const memberRepo = manager.getRepository(RoomMemberEntity);
|
||||
const members = normalizeRoomMembers(options.members);
|
||||
|
||||
await memberRepo.delete({ roomId });
|
||||
|
||||
if (members.length > 0) {
|
||||
await memberRepo.insert(
|
||||
members.map((member) => ({
|
||||
roomId,
|
||||
memberKey: memberKey(member),
|
||||
id: member.id,
|
||||
oderId: member.oderId ?? null,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl ?? null,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
lastSeenAt: member.lastSeenAt
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRoomRelationsMap(
|
||||
dataSource: DataSource,
|
||||
roomIds: readonly string[]
|
||||
): Promise<Map<string, RoomRelationRecord>> {
|
||||
const groupedRelations = new Map<string, RoomRelationRecord>();
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
const [channelRows, memberRows] = await Promise.all([
|
||||
dataSource.getRepository(RoomChannelEntity).find({
|
||||
where: { roomId: In([...roomIds]) }
|
||||
}),
|
||||
dataSource.getRepository(RoomMemberEntity).find({
|
||||
where: { roomId: In([...roomIds]) }
|
||||
})
|
||||
]);
|
||||
|
||||
for (const row of channelRows) {
|
||||
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
||||
|
||||
relation.channels.push({
|
||||
id: row.channelId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
position: row.position
|
||||
});
|
||||
groupedRelations.set(row.roomId, relation);
|
||||
}
|
||||
|
||||
for (const row of memberRows) {
|
||||
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
||||
|
||||
relation.members.push({
|
||||
id: row.id,
|
||||
oderId: row.oderId ?? undefined,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
role: row.role,
|
||||
joinedAt: row.joinedAt,
|
||||
lastSeenAt: row.lastSeenAt
|
||||
});
|
||||
groupedRelations.set(row.roomId, relation);
|
||||
}
|
||||
|
||||
for (const relation of groupedRelations.values()) {
|
||||
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
|
||||
relation.members.sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
return groupedRelations;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
@@ -40,6 +42,8 @@ export async function initializeDatabase(): Promise<void> {
|
||||
MessageEntity,
|
||||
UserEntity,
|
||||
RoomEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity,
|
||||
ReactionEntity,
|
||||
BanEntity,
|
||||
AttachmentEntity,
|
||||
|
||||
@@ -30,9 +30,6 @@ export class MessageEntity {
|
||||
@Column('integer', { nullable: true })
|
||||
editedAt!: number | null;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
reactions!: string;
|
||||
|
||||
@Column('integer', { default: 0 })
|
||||
isDeleted!: number;
|
||||
|
||||
|
||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_channels')
|
||||
export class RoomChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
@@ -48,12 +48,6 @@ export class RoomEntity {
|
||||
@Column('text', { nullable: true })
|
||||
permissions!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
channels!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
members!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
sourceId!: string | null;
|
||||
|
||||
|
||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('room_members')
|
||||
export class RoomMemberEntity {
|
||||
@PrimaryColumn('text')
|
||||
roomId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
memberKey!: string;
|
||||
|
||||
@Column('text')
|
||||
id!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
oderId!: string | null;
|
||||
|
||||
@Column('text')
|
||||
username!: string;
|
||||
|
||||
@Column('text')
|
||||
displayName!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
|
||||
@Column('text')
|
||||
role!: 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
@Column('integer')
|
||||
joinedAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
lastSeenAt!: number;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export { MessageEntity } from './MessageEntity';
|
||||
export { UserEntity } from './UserEntity';
|
||||
export { RoomEntity } from './RoomEntity';
|
||||
export { RoomChannelEntity } from './RoomChannelEntity';
|
||||
export { RoomMemberEntity } from './RoomMemberEntity';
|
||||
export { ReactionEntity } from './ReactionEntity';
|
||||
export { BanEntity } from './BanEntity';
|
||||
export { AttachmentEntity } from './AttachmentEntity';
|
||||
|
||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyMessageRow = {
|
||||
id: string;
|
||||
reactions: string | null;
|
||||
};
|
||||
|
||||
type LegacyRoomRow = {
|
||||
id: string;
|
||||
channels: string | null;
|
||||
members: string | null;
|
||||
};
|
||||
|
||||
type ChannelType = 'text' | 'voice';
|
||||
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
type LegacyReaction = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
userId?: unknown;
|
||||
emoji?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
type LegacyRoomMember = {
|
||||
id?: unknown;
|
||||
oderId?: unknown;
|
||||
username?: unknown;
|
||||
displayName?: unknown;
|
||||
avatarUrl?: unknown;
|
||||
role?: unknown;
|
||||
joinedAt?: unknown;
|
||||
lastSeenAt?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ChannelType, name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||
return member.oderId?.trim() || member.id?.trim() || '';
|
||||
}
|
||||
|
||||
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||
? value
|
||||
: 'member';
|
||||
}
|
||||
|
||||
function mergeRoomMemberRole(
|
||||
existingRole: RoomMemberRole,
|
||||
incomingRole: RoomMemberRole,
|
||||
preferIncoming: boolean
|
||||
): RoomMemberRole {
|
||||
if (existingRole === incomingRole) {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||
return incomingRole;
|
||||
}
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function compareRoomMembers(
|
||||
firstMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
},
|
||||
secondMember: {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
displayName: string;
|
||||
}
|
||||
): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0) {
|
||||
return displayNameCompare;
|
||||
}
|
||||
|
||||
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||
}
|
||||
|
||||
function normalizeMessageReactions(messageId: string, raw: string | null) {
|
||||
const reactions = parseArray<LegacyReaction>(raw);
|
||||
const seen = new Set<string>();
|
||||
|
||||
return reactions.flatMap((reaction) => {
|
||||
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
|
||||
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
|
||||
const dedupeKey = `${userId}:${emoji}`;
|
||||
|
||||
if (!emoji || seen.has(dedupeKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seen.add(dedupeKey);
|
||||
|
||||
return [{
|
||||
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
|
||||
messageId,
|
||||
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
|
||||
userId: userId || null,
|
||||
emoji,
|
||||
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyRoomChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
|
||||
const members = parseArray<LegacyRoomMember>(raw);
|
||||
const membersByKey = new Map<string, {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: RoomMemberRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}>();
|
||||
|
||||
for (const rawMember of members) {
|
||||
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
|
||||
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
|
||||
const key = normalizedOderId || normalizedId;
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
|
||||
? rawMember.lastSeenAt
|
||||
: isFiniteNumber(rawMember.joinedAt)
|
||||
? rawMember.joinedAt
|
||||
: now;
|
||||
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
|
||||
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
|
||||
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
|
||||
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
|
||||
const nextMember = {
|
||||
id: normalizedId || key,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
role: normalizeRoomMemberRole(rawMember.role),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
const existingMember = membersByKey.get(key);
|
||||
|
||||
if (!existingMember) {
|
||||
membersByKey.set(key, nextMember);
|
||||
continue;
|
||||
}
|
||||
|
||||
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
|
||||
membersByKey.set(key, {
|
||||
id: existingMember.id || nextMember.id,
|
||||
oderId: nextMember.oderId || existingMember.oderId,
|
||||
username: preferIncoming
|
||||
? (nextMember.username || existingMember.username)
|
||||
: (existingMember.username || nextMember.username),
|
||||
displayName: preferIncoming
|
||||
? (nextMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || nextMember.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (nextMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || nextMember.avatarUrl),
|
||||
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
|
||||
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
|
||||
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
|
||||
name = 'NormalizeArrayColumns1000000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_channels" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "room_members" (
|
||||
"roomId" TEXT NOT NULL,
|
||||
"memberKey" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"oderId" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarUrl" TEXT,
|
||||
"role" TEXT NOT NULL,
|
||||
"joinedAt" INTEGER NOT NULL,
|
||||
"lastSeenAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("roomId", "memberKey")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
|
||||
|
||||
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
|
||||
|
||||
for (const row of messageRows) {
|
||||
const reactions = normalizeMessageReactions(row.id, row.reactions);
|
||||
|
||||
for (const reaction of reactions) {
|
||||
const existing = await queryRunner.query(
|
||||
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
|
||||
[reaction.messageId, reaction.userId, reaction.emoji]
|
||||
) as Array<{ 1: number }>;
|
||||
|
||||
if (existing.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
|
||||
|
||||
for (const row of roomRows) {
|
||||
for (const channel of normalizeRoomChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
|
||||
for (const member of normalizeRoomMembers(row.members)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
memberKey(member),
|
||||
member.id,
|
||||
member.oderId ?? null,
|
||||
member.username,
|
||||
member.displayName,
|
||||
member.avatarUrl ?? null,
|
||||
member.role,
|
||||
member.joinedAt,
|
||||
member.lastSeenAt
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "messages_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"channelId" TEXT,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"senderName" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"timestamp" INTEGER NOT NULL,
|
||||
"editedAt" INTEGER,
|
||||
"isDeleted" INTEGER NOT NULL DEFAULT 0,
|
||||
"replyToId" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
|
||||
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
|
||||
FROM "messages"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "messages"`);
|
||||
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "rooms_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"topic" TEXT,
|
||||
"hostId" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"userCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER,
|
||||
"icon" TEXT,
|
||||
"iconUpdatedAt" INTEGER,
|
||||
"permissions" TEXT,
|
||||
"hasPassword" INTEGER NOT NULL DEFAULT 0,
|
||||
"sourceId" TEXT,
|
||||
"sourceName" TEXT,
|
||||
"sourceUrl" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
|
||||
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
|
||||
FROM "rooms"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "rooms"`);
|
||||
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
ServerChannelEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -11,9 +13,13 @@ import { DeleteServerCommand } from '../../types';
|
||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const { serverId } = command.payload;
|
||||
|
||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
||||
await dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(ServerTagEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerChannelEntity).delete({ serverId });
|
||||
await manager.getRepository(JoinRequestEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerInviteEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerBanEntity).delete({ serverId });
|
||||
await manager.getRepository(ServerEntity).delete(serverId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { replaceServerRelations } from '../../relations';
|
||||
import { UpsertServerCommand } from '../../types';
|
||||
|
||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const { server } = command.payload;
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
tags: JSON.stringify(server.tags),
|
||||
channels: JSON.stringify(server.channels ?? []),
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(ServerEntity);
|
||||
const entity = repo.create({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
description: server.description ?? null,
|
||||
ownerId: server.ownerId,
|
||||
ownerPublicKey: server.ownerPublicKey,
|
||||
passwordHash: server.passwordHash ?? null,
|
||||
isPrivate: server.isPrivate ? 1 : 0,
|
||||
maxUsers: server.maxUsers,
|
||||
currentUsers: server.currentUsers,
|
||||
createdAt: server.createdAt,
|
||||
lastSeen: server.lastSeen
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
await repo.save(entity);
|
||||
await replaceServerRelations(manager, server.id, {
|
||||
tags: server.tags,
|
||||
channels: server.channels ?? []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,67 +3,10 @@ import { ServerEntity } from '../entities/ServerEntity';
|
||||
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
|
||||
import {
|
||||
AuthUserPayload,
|
||||
ServerChannelPayload,
|
||||
ServerPayload,
|
||||
JoinRequestPayload
|
||||
} from './types';
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${name.toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function parseStringArray(raw: string | null | undefined): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return parsed
|
||||
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
|
||||
.map((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = typeof channel.position === 'number' ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
} satisfies ServerChannelPayload;
|
||||
})
|
||||
.filter((channel): channel is ServerChannelPayload => !!channel);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -74,7 +17,10 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
export function rowToServer(
|
||||
row: ServerEntity,
|
||||
relations: Pick<ServerPayload, 'tags' | 'channels'> = { tags: [], channels: [] }
|
||||
): ServerPayload {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -86,8 +32,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
|
||||
isPrivate: !!row.isPrivate,
|
||||
maxUsers: row.maxUsers,
|
||||
currentUsers: row.currentUsers,
|
||||
tags: parseStringArray(row.tags),
|
||||
channels: parseServerChannels(row.channels),
|
||||
tags: relations.tags ?? [],
|
||||
channels: relations.channels ?? [],
|
||||
createdAt: row.createdAt,
|
||||
lastSeen: row.lastSeen
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
|
||||
|
||||
return rows.map(rowToServer);
|
||||
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
||||
import { ServerEntity } from '../../../entities';
|
||||
import { GetServerByIdQuery } from '../../types';
|
||||
import { rowToServer } from '../../mappers';
|
||||
import { loadServerRelationsMap } from '../../relations';
|
||||
|
||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(ServerEntity);
|
||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
||||
|
||||
return row ? rowToServer(row) : null;
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
|
||||
|
||||
return rowToServer(row, relationsByServerId.get(row.id));
|
||||
}
|
||||
|
||||
160
server/src/cqrs/relations.ts
Normal file
160
server/src/cqrs/relations.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
In
|
||||
} from 'typeorm';
|
||||
import {
|
||||
ServerChannelEntity,
|
||||
ServerTagEntity
|
||||
} from '../entities';
|
||||
import { ServerChannelPayload } from './types';
|
||||
|
||||
interface ServerRelationRecord {
|
||||
tags: string[];
|
||||
channels: ServerChannelPayload[];
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function normalizeServerTags(rawTags: unknown): string[] {
|
||||
if (!Array.isArray(rawTags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawTags.filter((tag): tag is string => typeof tag === 'string');
|
||||
}
|
||||
|
||||
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
|
||||
if (!Array.isArray(rawChannels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: ServerChannelPayload[] = [];
|
||||
|
||||
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channel = rawChannel as Record<string, unknown>;
|
||||
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export async function replaceServerRelations(
|
||||
manager: EntityManager,
|
||||
serverId: string,
|
||||
options: { tags: unknown; channels: unknown }
|
||||
): Promise<void> {
|
||||
const tagRepo = manager.getRepository(ServerTagEntity);
|
||||
const channelRepo = manager.getRepository(ServerChannelEntity);
|
||||
const tags = normalizeServerTags(options.tags);
|
||||
const channels = normalizeServerChannels(options.channels);
|
||||
|
||||
await tagRepo.delete({ serverId });
|
||||
await channelRepo.delete({ serverId });
|
||||
|
||||
if (tags.length > 0) {
|
||||
await tagRepo.insert(
|
||||
tags.map((tag, position) => ({
|
||||
serverId,
|
||||
position,
|
||||
value: tag
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
await channelRepo.insert(
|
||||
channels.map((channel) => ({
|
||||
serverId,
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
position: channel.position
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadServerRelationsMap(
|
||||
dataSource: DataSource,
|
||||
serverIds: readonly string[]
|
||||
): Promise<Map<string, ServerRelationRecord>> {
|
||||
const groupedRelations = new Map<string, ServerRelationRecord>();
|
||||
|
||||
if (serverIds.length === 0) {
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
const [tagRows, channelRows] = await Promise.all([
|
||||
dataSource.getRepository(ServerTagEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
}),
|
||||
dataSource.getRepository(ServerChannelEntity).find({
|
||||
where: { serverId: In([...serverIds]) }
|
||||
})
|
||||
]);
|
||||
|
||||
for (const row of tagRows) {
|
||||
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
|
||||
|
||||
relation.tags.push(row.value);
|
||||
groupedRelations.set(row.serverId, relation);
|
||||
}
|
||||
|
||||
for (const row of channelRows) {
|
||||
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
|
||||
|
||||
relation.channels.push({
|
||||
id: row.channelId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
position: row.position
|
||||
});
|
||||
groupedRelations.set(row.serverId, relation);
|
||||
}
|
||||
|
||||
for (const [serverId, relation] of groupedRelations) {
|
||||
const orderedTags = tagRows
|
||||
.filter((row) => row.serverId === serverId)
|
||||
.sort((first, second) => first.position - second.position)
|
||||
.map((row) => row.value);
|
||||
|
||||
relation.tags = orderedTags;
|
||||
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
|
||||
}
|
||||
|
||||
return groupedRelations;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { DataSource } from 'typeorm';
|
||||
import {
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
@@ -54,6 +56,8 @@ export async function initDatabase(): Promise<void> {
|
||||
entities: [
|
||||
AuthUserEntity,
|
||||
ServerEntity,
|
||||
ServerTagEntity,
|
||||
ServerChannelEntity,
|
||||
JoinRequestEntity,
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
|
||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_channels')
|
||||
export class ServerChannelEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
channelId!: string;
|
||||
|
||||
@Column('text')
|
||||
name!: string;
|
||||
|
||||
@Column('text')
|
||||
type!: 'text' | 'voice';
|
||||
|
||||
@Column('integer')
|
||||
position!: number;
|
||||
}
|
||||
@@ -33,12 +33,6 @@ export class ServerEntity {
|
||||
@Column('integer', { default: 0 })
|
||||
currentUsers!: number;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
tags!: string;
|
||||
|
||||
@Column('text', { default: '[]' })
|
||||
channels!: string;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
|
||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_tags')
|
||||
export class ServerTagEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('integer')
|
||||
position!: number;
|
||||
|
||||
@Column('text')
|
||||
value!: string;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export { AuthUserEntity } from './AuthUserEntity';
|
||||
export { ServerEntity } from './ServerEntity';
|
||||
export { ServerTagEntity } from './ServerTagEntity';
|
||||
export { ServerChannelEntity } from './ServerChannelEntity';
|
||||
export { JoinRequestEntity } from './JoinRequestEntity';
|
||||
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
|
||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
type LegacyServerRow = {
|
||||
id: string;
|
||||
tags: string | null;
|
||||
channels: string | null;
|
||||
};
|
||||
|
||||
type LegacyServerChannel = {
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
type?: unknown;
|
||||
position?: unknown;
|
||||
};
|
||||
|
||||
function parseArray<T>(raw: string | null): T[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw || '[]');
|
||||
|
||||
return Array.isArray(parsed) ? parsed as T[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: 'text' | 'voice', name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeServerTags(raw: string | null): string[] {
|
||||
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
|
||||
}
|
||||
|
||||
function normalizeServerChannels(raw: string | null) {
|
||||
const channels = parseArray<LegacyServerChannel>(raw);
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
return channels.flatMap((channel, index) => {
|
||||
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
|
||||
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
|
||||
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
|
||||
const position = isFiniteNumber(channel.position) ? channel.position : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
|
||||
return [{
|
||||
channelId: id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
|
||||
name = 'NormalizeServerArrays1000000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_tags" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
PRIMARY KEY ("serverId", "position")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_channels" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "channelId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
|
||||
|
||||
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
|
||||
[row.id, position, tag]
|
||||
);
|
||||
}
|
||||
|
||||
for (const channel of normalizeServerChannels(row.channels)) {
|
||||
await queryRunner.query(
|
||||
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
|
||||
[row.id, channel.channelId, channel.name, channel.type, channel.position]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "servers_next" (
|
||||
"id" TEXT PRIMARY KEY NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"ownerPublicKey" TEXT NOT NULL,
|
||||
"isPrivate" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"lastSeen" INTEGER NOT NULL,
|
||||
"passwordHash" TEXT
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
|
||||
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
|
||||
FROM "servers"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE "servers"`);
|
||||
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
||||
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
|
||||
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
|
||||
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
ServerAccessControl1000000000001,
|
||||
ServerChannels1000000000002,
|
||||
RepairLegacyVoiceChannels1000000000003
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004
|
||||
];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { getUserById } from '../cqrs';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
import { getActiveServerInvite } from '../services/server-access.service';
|
||||
import {
|
||||
@@ -283,7 +282,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const server = bundle.server;
|
||||
|
||||
res.json({
|
||||
id: bundle.invite.id,
|
||||
@@ -315,7 +314,7 @@ invitePageRouter.get('/:id', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const server = rowToServer(bundle.server);
|
||||
const server = bundle.server;
|
||||
const owner = await getUserById(server.ownerId);
|
||||
|
||||
res.send(renderInvitePage({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ServerMembershipEntity
|
||||
} from '../entities';
|
||||
import { rowToServer } from '../cqrs/mappers';
|
||||
import { loadServerRelationsMap } from '../cqrs/relations';
|
||||
import { ServerPayload } from '../cqrs/types';
|
||||
|
||||
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
|
||||
@@ -57,6 +58,12 @@ function getBanRepository() {
|
||||
return getDataSource().getRepository(ServerBanEntity);
|
||||
}
|
||||
|
||||
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
|
||||
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
|
||||
|
||||
return rowToServer(server, relationsByServerId.get(server.id));
|
||||
}
|
||||
|
||||
function normalizePassword(password?: string | null): string | null {
|
||||
const normalized = password?.trim() ?? '';
|
||||
|
||||
@@ -194,7 +201,7 @@ export async function createServerInvite(
|
||||
|
||||
export async function getActiveServerInvite(
|
||||
inviteId: string
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
|
||||
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
|
||||
await pruneExpiredServerAccessArtifacts();
|
||||
|
||||
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
|
||||
@@ -214,7 +221,10 @@ export async function getActiveServerInvite(
|
||||
return null;
|
||||
}
|
||||
|
||||
return { invite, server };
|
||||
return {
|
||||
invite,
|
||||
server: await toServerPayload(server)
|
||||
};
|
||||
}
|
||||
|
||||
export async function joinServerWithAccess(options: {
|
||||
@@ -242,7 +252,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -260,7 +270,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: !!existingMembership,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'invite'
|
||||
};
|
||||
}
|
||||
@@ -272,7 +282,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: true,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'membership'
|
||||
};
|
||||
}
|
||||
@@ -288,7 +298,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'password'
|
||||
};
|
||||
}
|
||||
@@ -301,7 +311,7 @@ export async function joinServerWithAccess(options: {
|
||||
|
||||
return {
|
||||
joinedBefore: false,
|
||||
server: rowToServer(server),
|
||||
server: await toServerPayload(server),
|
||||
via: 'public'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ sequenceDiagram
|
||||
|
||||
The renderer sends structured command/query objects through the Electron preload bridge. The main process handles them with TypeORM against a local SQLite file. No database logic runs in the renderer.
|
||||
|
||||
The Electron schema now normalises reaction rows and room channel/member rosters into separate SQLite tables instead of storing those arrays inline on the parent message or room rows. The renderer-facing API is unchanged: CQRS handlers rehydrate the same `Message` and `Room` payloads before returning them over IPC.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Eff as NgRx Effect
|
||||
|
||||
Reference in New Issue
Block a user