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; }