Files
Toju/electron/cqrs/relations.ts

426 lines
12 KiB
TypeScript

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