diff --git a/electron/cqrs/commands/handlers/clearAllData.ts b/electron/cqrs/commands/handlers/clearAllData.ts index 93fe179..953af5e 100644 --- a/electron/cqrs/commands/handlers/clearAllData.ts +++ b/electron/cqrs/commands/handlers/clearAllData.ts @@ -3,6 +3,8 @@ import { MessageEntity, UserEntity, RoomEntity, + RoomChannelEntity, + RoomMemberEntity, ReactionEntity, BanEntity, AttachmentEntity, @@ -13,6 +15,8 @@ export async function handleClearAllData(dataSource: DataSource): Promise 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(); diff --git a/electron/cqrs/commands/handlers/deleteRoom.ts b/electron/cqrs/commands/handlers/deleteRoom.ts index 555cfad..f8caeb5 100644 --- a/electron/cqrs/commands/handlers/deleteRoom.ts +++ b/electron/cqrs/commands/handlers/deleteRoom.ts @@ -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 { 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 }); + }); } diff --git a/electron/cqrs/commands/handlers/saveMessage.ts b/electron/cqrs/commands/handlers/saveMessage.ts index 6622e72..20c6ade 100644 --- a/electron/cqrs/commands/handlers/saveMessage.ts +++ b/electron/cqrs/commands/handlers/saveMessage.ts @@ -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 { - 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 ?? []); + }); } diff --git a/electron/cqrs/commands/handlers/saveRoom.ts b/electron/cqrs/commands/handlers/saveRoom.ts index 0b27267..8af66e9 100644 --- a/electron/cqrs/commands/handlers/saveRoom.ts +++ b/electron/cqrs/commands/handlers/saveRoom.ts @@ -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 { - 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 ?? [] + }); + }); } diff --git a/electron/cqrs/commands/handlers/updateMessage.ts b/electron/cqrs/commands/handlers/updateMessage.ts index 6b8bbac..520348b 100644 --- a/electron/cqrs/commands/handlers/updateMessage.ts +++ b/electron/cqrs/commands/handlers/updateMessage.ts @@ -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 { - 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 ?? []); + } + }); } diff --git a/electron/cqrs/commands/handlers/updateRoom.ts b/electron/cqrs/commands/handlers/updateRoom.ts index 8803c65..7e0dd1f 100644 --- a/electron/cqrs/commands/handlers/updateRoom.ts +++ b/electron/cqrs/commands/handlers/updateRoom.ts @@ -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 { - 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 + }); + }); } diff --git a/electron/cqrs/mappers.ts b/electron/cqrs/mappers.ts index 8d2f9ec..1be1279 100644 --- a/electron/cqrs/mappers.ts +++ b/electron/cqrs/mappers.ts @@ -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 = { 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 diff --git a/electron/cqrs/queries/handlers/getAllRooms.ts b/electron/cqrs/queries/handlers/getAllRooms.ts index e0994b7..19ccc1f 100644 --- a/electron/cqrs/queries/handlers/getAllRooms.ts +++ b/electron/cqrs/queries/handlers/getAllRooms.ts @@ -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))); } diff --git a/electron/cqrs/queries/handlers/getMessageById.ts b/electron/cqrs/queries/handlers/getMessageById.ts index 8cc2574..c35dde7 100644 --- a/electron/cqrs/queries/handlers/getMessageById.ts +++ b/electron/cqrs/queries/handlers/getMessageById.ts @@ -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) ?? []); } diff --git a/electron/cqrs/queries/handlers/getMessages.ts b/electron/cqrs/queries/handlers/getMessages.ts index ee45493..d887776 100644 --- a/electron/cqrs/queries/handlers/getMessages.ts +++ b/electron/cqrs/queries/handlers/getMessages.ts @@ -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) ?? [])); } diff --git a/electron/cqrs/queries/handlers/getMessagesSince.ts b/electron/cqrs/queries/handlers/getMessagesSince.ts index e8a628c..3e61b06 100644 --- a/electron/cqrs/queries/handlers/getMessagesSince.ts +++ b/electron/cqrs/queries/handlers/getMessagesSince.ts @@ -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) ?? [])); } diff --git a/electron/cqrs/queries/handlers/getRoom.ts b/electron/cqrs/queries/handlers/getRoom.ts index a89251b..8de0b54 100644 --- a/electron/cqrs/queries/handlers/getRoom.ts +++ b/electron/cqrs/queries/handlers/getRoom.ts @@ -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)); } diff --git a/electron/cqrs/relations.ts b/electron/cqrs/relations.ts new file mode 100644 index 0000000..dcec415 --- /dev/null +++ b/electron/cqrs/relations.ts @@ -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): string { + return member.displayName || member.username || member.oderId || member.id || 'User'; +} + +function fallbackUsername(member: Partial): 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, 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(); + const reactions: ReactionPayload[] = []; + + for (const rawReaction of rawReactions) { + if (!rawReaction || typeof rawReaction !== 'object') { + continue; + } + + const reaction = rawReaction as Record; + 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(); + const seenNames = new Set(); + const channels: RoomChannelRecord[] = []; + + for (const [index, rawChannel] of rawChannels.entries()) { + if (!rawChannel || typeof rawChannel !== 'object') { + continue; + } + + const channel = rawChannel as Record; + 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(); + + for (const rawMember of rawMembers) { + if (!rawMember || typeof rawMember !== 'object') { + continue; + } + + const member = normalizeRoomMember(rawMember as Record, 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 { + 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> { + const groupedReactions = new Map(); + + 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 { + 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> { + const groupedRelations = new Map(); + + 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; +} \ No newline at end of file diff --git a/electron/db/database.ts b/electron/db/database.ts index 3a31b30..577be1e 100644 --- a/electron/db/database.ts +++ b/electron/db/database.ts @@ -7,6 +7,8 @@ import { MessageEntity, UserEntity, RoomEntity, + RoomChannelEntity, + RoomMemberEntity, ReactionEntity, BanEntity, AttachmentEntity, @@ -40,6 +42,8 @@ export async function initializeDatabase(): Promise { MessageEntity, UserEntity, RoomEntity, + RoomChannelEntity, + RoomMemberEntity, ReactionEntity, BanEntity, AttachmentEntity, diff --git a/electron/entities/MessageEntity.ts b/electron/entities/MessageEntity.ts index 3987692..c7f99eb 100644 --- a/electron/entities/MessageEntity.ts +++ b/electron/entities/MessageEntity.ts @@ -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; diff --git a/electron/entities/RoomChannelEntity.ts b/electron/entities/RoomChannelEntity.ts new file mode 100644 index 0000000..1b87d48 --- /dev/null +++ b/electron/entities/RoomChannelEntity.ts @@ -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; +} \ No newline at end of file diff --git a/electron/entities/RoomEntity.ts b/electron/entities/RoomEntity.ts index 72d2404..47e9dbb 100644 --- a/electron/entities/RoomEntity.ts +++ b/electron/entities/RoomEntity.ts @@ -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; diff --git a/electron/entities/RoomMemberEntity.ts b/electron/entities/RoomMemberEntity.ts new file mode 100644 index 0000000..888a859 --- /dev/null +++ b/electron/entities/RoomMemberEntity.ts @@ -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; +} \ No newline at end of file diff --git a/electron/entities/index.ts b/electron/entities/index.ts index 63dc540..e09f97a 100644 --- a/electron/entities/index.ts +++ b/electron/entities/index.ts @@ -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'; diff --git a/electron/migrations/1000000000003-NormalizeArrayColumns.ts b/electron/migrations/1000000000003-NormalizeArrayColumns.ts new file mode 100644 index 0000000..4b93fef --- /dev/null +++ b/electron/migrations/1000000000003-NormalizeArrayColumns.ts @@ -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(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(raw); + const seen = new Set(); + + 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(raw); + const seenIds = new Set(); + const seenNames = new Set(); + + 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(raw); + const membersByKey = new Map(); + + 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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`); + await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`); + } +} \ No newline at end of file diff --git a/server/src/cqrs/commands/handlers/deleteServer.ts b/server/src/cqrs/commands/handlers/deleteServer.ts index 091c51a..fa764b2 100644 --- a/server/src/cqrs/commands/handlers/deleteServer.ts +++ b/server/src/cqrs/commands/handlers/deleteServer.ts @@ -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 { 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); + }); } diff --git a/server/src/cqrs/commands/handlers/upsertServer.ts b/server/src/cqrs/commands/handlers/upsertServer.ts index f45d23d..782932b 100644 --- a/server/src/cqrs/commands/handlers/upsertServer.ts +++ b/server/src/cqrs/commands/handlers/upsertServer.ts @@ -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 { - 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 ?? [] + }); + }); } diff --git a/server/src/cqrs/mappers.ts b/server/src/cqrs/mappers.ts index 78bae3a..6fee974 100644 --- a/server/src/cqrs/mappers.ts +++ b/server/src/cqrs/mappers.ts @@ -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(); - const seenNames = new Set(); - - return parsed - .filter((channel): channel is Record => !!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 = { 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 }; diff --git a/server/src/cqrs/queries/handlers/getAllPublicServers.ts b/server/src/cqrs/queries/handlers/getAllPublicServers.ts index ea88ceb..197d9ab 100644 --- a/server/src/cqrs/queries/handlers/getAllPublicServers.ts +++ b/server/src/cqrs/queries/handlers/getAllPublicServers.ts @@ -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))); } diff --git a/server/src/cqrs/queries/handlers/getServerById.ts b/server/src/cqrs/queries/handlers/getServerById.ts index 95eecaf..1a7a22c 100644 --- a/server/src/cqrs/queries/handlers/getServerById.ts +++ b/server/src/cqrs/queries/handlers/getServerById.ts @@ -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)); } diff --git a/server/src/cqrs/relations.ts b/server/src/cqrs/relations.ts new file mode 100644 index 0000000..a70c5cb --- /dev/null +++ b/server/src/cqrs/relations.ts @@ -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(); + const seenNames = new Set(); + const channels: ServerChannelPayload[] = []; + + for (const [index, rawChannel] of rawChannels.entries()) { + if (!rawChannel || typeof rawChannel !== 'object') { + continue; + } + + const channel = rawChannel as Record; + 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 { + 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> { + const groupedRelations = new Map(); + + 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; +} \ No newline at end of file diff --git a/server/src/db/database.ts b/server/src/db/database.ts index d9510cd..b76d4bb 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -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 { entities: [ AuthUserEntity, ServerEntity, + ServerTagEntity, + ServerChannelEntity, JoinRequestEntity, ServerMembershipEntity, ServerInviteEntity, diff --git a/server/src/entities/ServerChannelEntity.ts b/server/src/entities/ServerChannelEntity.ts new file mode 100644 index 0000000..577d663 --- /dev/null +++ b/server/src/entities/ServerChannelEntity.ts @@ -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; +} \ No newline at end of file diff --git a/server/src/entities/ServerEntity.ts b/server/src/entities/ServerEntity.ts index f978ab0..3457428 100644 --- a/server/src/entities/ServerEntity.ts +++ b/server/src/entities/ServerEntity.ts @@ -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; diff --git a/server/src/entities/ServerTagEntity.ts b/server/src/entities/ServerTagEntity.ts new file mode 100644 index 0000000..f5570b5 --- /dev/null +++ b/server/src/entities/ServerTagEntity.ts @@ -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; +} \ No newline at end of file diff --git a/server/src/entities/index.ts b/server/src/entities/index.ts index 1066935..d0b4cde 100644 --- a/server/src/entities/index.ts +++ b/server/src/entities/index.ts @@ -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'; diff --git a/server/src/migrations/1000000000004-NormalizeServerArrays.ts b/server/src/migrations/1000000000004-NormalizeServerArrays.ts new file mode 100644 index 0000000..dd45701 --- /dev/null +++ b/server/src/migrations/1000000000004-NormalizeServerArrays.ts @@ -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(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(raw).filter((tag): tag is string => typeof tag === 'string'); +} + +function normalizeServerChannels(raw: string | null) { + const channels = parseArray(raw); + const seenIds = new Set(); + const seenNames = new Set(); + + 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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`); + await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`); + } +} \ No newline at end of file diff --git a/server/src/migrations/index.ts b/server/src/migrations/index.ts index 4614762..1dfd2b7 100644 --- a/server/src/migrations/index.ts +++ b/server/src/migrations/index.ts @@ -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 ]; diff --git a/server/src/routes/invites.ts b/server/src/routes/invites.ts index e501c10..8d1e4b0 100644 --- a/server/src/routes/invites.ts +++ b/server/src/routes/invites.ts @@ -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({ diff --git a/server/src/services/server-access.service.ts b/server/src/services/server-access.service.ts index 805a20b..20902fd 100644 --- a/server/src/services/server-access.service.ts +++ b/server/src/services/server-access.service.ts @@ -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 { + 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' }; } diff --git a/toju-app/src/app/infrastructure/persistence/README.md b/toju-app/src/app/infrastructure/persistence/README.md index 5a1202d..a545a48 100644 --- a/toju-app/src/app/infrastructure/persistence/README.md +++ b/toju-app/src/app/infrastructure/persistence/README.md @@ -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