Database changes to make it better practise
This commit is contained in:
426
electron/cqrs/relations.ts
Normal file
426
electron/cqrs/relations.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
In
|
||||
} from 'typeorm';
|
||||
import {
|
||||
ReactionEntity,
|
||||
RoomChannelEntity,
|
||||
RoomMemberEntity
|
||||
} from '../entities';
|
||||
import { ReactionPayload } from './types';
|
||||
|
||||
type ChannelType = 'text' | 'voice';
|
||||
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
|
||||
export interface RoomChannelRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface RoomMemberRecord {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: RoomMemberRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
|
||||
interface RoomRelationRecord {
|
||||
channels: RoomChannelRecord[];
|
||||
members: RoomMemberRecord[];
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function normalizeChannelName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function channelNameKey(type: ChannelType, name: string): string {
|
||||
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
|
||||
}
|
||||
|
||||
function memberKey(member: { id?: string; oderId?: string }): string {
|
||||
return member.oderId?.trim() || member.id?.trim() || '';
|
||||
}
|
||||
|
||||
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
|
||||
return member.displayName || member.username || member.oderId || member.id || 'User';
|
||||
}
|
||||
|
||||
function fallbackUsername(member: Partial<RoomMemberRecord>): string {
|
||||
const base = fallbackDisplayName(member)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '_');
|
||||
|
||||
return base || member.oderId || member.id || 'user';
|
||||
}
|
||||
|
||||
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
|
||||
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
|
||||
? value
|
||||
: 'member';
|
||||
}
|
||||
|
||||
function mergeRoomMemberRole(
|
||||
existingRole: RoomMemberRole,
|
||||
incomingRole: RoomMemberRole,
|
||||
preferIncoming: boolean
|
||||
): RoomMemberRole {
|
||||
if (existingRole === incomingRole) {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (incomingRole === 'member' && existingRole !== 'member') {
|
||||
return existingRole;
|
||||
}
|
||||
|
||||
if (existingRole === 'member' && incomingRole !== 'member') {
|
||||
return incomingRole;
|
||||
}
|
||||
|
||||
return preferIncoming ? incomingRole : existingRole;
|
||||
}
|
||||
|
||||
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
|
||||
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
|
||||
|
||||
if (displayNameCompare !== 0) {
|
||||
return displayNameCompare;
|
||||
}
|
||||
|
||||
return memberKey(firstMember).localeCompare(memberKey(secondMember));
|
||||
}
|
||||
|
||||
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
|
||||
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
|
||||
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
|
||||
const normalizedKey = normalizedOderId || normalizedId;
|
||||
|
||||
if (!normalizedKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
|
||||
? rawMember['lastSeenAt']
|
||||
: isFiniteNumber(rawMember['joinedAt'])
|
||||
? rawMember['joinedAt']
|
||||
: now;
|
||||
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
|
||||
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
|
||||
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
|
||||
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
|
||||
|
||||
const member: RoomMemberRecord = {
|
||||
id: normalizedId || normalizedKey,
|
||||
oderId: normalizedOderId || undefined,
|
||||
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
|
||||
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
|
||||
avatarUrl: avatarUrl || undefined,
|
||||
role: normalizeRoomMemberRole(rawMember['role']),
|
||||
joinedAt,
|
||||
lastSeenAt
|
||||
};
|
||||
|
||||
return member;
|
||||
}
|
||||
|
||||
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
|
||||
if (!existingMember) {
|
||||
return incomingMember;
|
||||
}
|
||||
|
||||
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
|
||||
|
||||
return {
|
||||
id: existingMember.id || incomingMember.id,
|
||||
oderId: incomingMember.oderId || existingMember.oderId,
|
||||
username: preferIncoming
|
||||
? (incomingMember.username || existingMember.username)
|
||||
: (existingMember.username || incomingMember.username),
|
||||
displayName: preferIncoming
|
||||
? (incomingMember.displayName || existingMember.displayName)
|
||||
: (existingMember.displayName || incomingMember.displayName),
|
||||
avatarUrl: preferIncoming
|
||||
? (incomingMember.avatarUrl || existingMember.avatarUrl)
|
||||
: (existingMember.avatarUrl || incomingMember.avatarUrl),
|
||||
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
|
||||
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
|
||||
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
|
||||
if (!Array.isArray(rawReactions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const reactions: ReactionPayload[] = [];
|
||||
|
||||
for (const rawReaction of rawReactions) {
|
||||
if (!rawReaction || typeof rawReaction !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reaction = rawReaction as Record<string, unknown>;
|
||||
const emoji = typeof reaction['emoji'] === 'string' ? reaction['emoji'] : '';
|
||||
const userId = typeof reaction['userId'] === 'string' ? reaction['userId'] : '';
|
||||
const dedupeKey = `${userId}:${emoji}`;
|
||||
|
||||
if (!emoji || seen.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(dedupeKey);
|
||||
reactions.push({
|
||||
id: typeof reaction['id'] === 'string' && reaction['id'].trim() ? reaction['id'] : `${messageId}:${dedupeKey}`,
|
||||
messageId,
|
||||
oderId: typeof reaction['oderId'] === 'string' ? reaction['oderId'] : '',
|
||||
userId,
|
||||
emoji,
|
||||
timestamp: isFiniteNumber(reaction['timestamp']) ? reaction['timestamp'] : 0
|
||||
});
|
||||
}
|
||||
|
||||
return reactions;
|
||||
}
|
||||
|
||||
export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[] {
|
||||
if (!Array.isArray(rawChannels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const channels: RoomChannelRecord[] = [];
|
||||
|
||||
for (const [index, rawChannel] of rawChannels.entries()) {
|
||||
if (!rawChannel || typeof rawChannel !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const channel = rawChannel as Record<string, unknown>;
|
||||
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
|
||||
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
|
||||
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
|
||||
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
|
||||
const nameKey = type ? channelNameKey(type, name) : '';
|
||||
|
||||
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenIds.add(id);
|
||||
seenNames.add(nameKey);
|
||||
channels.push({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
position
|
||||
});
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
|
||||
if (!Array.isArray(rawMembers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const membersByKey = new Map<string, RoomMemberRecord>();
|
||||
|
||||
for (const rawMember of rawMembers) {
|
||||
if (!rawMember || typeof rawMember !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
|
||||
|
||||
if (!member) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = memberKey(member);
|
||||
|
||||
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
|
||||
}
|
||||
|
||||
return Array.from(membersByKey.values()).sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
export async function replaceMessageReactions(
|
||||
manager: EntityManager,
|
||||
messageId: string,
|
||||
rawReactions: unknown
|
||||
): Promise<void> {
|
||||
const repo = manager.getRepository(ReactionEntity);
|
||||
|
||||
await repo.delete({ messageId });
|
||||
|
||||
const reactions = normalizeReactionPayloads(rawReactions, messageId);
|
||||
|
||||
if (reactions.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await repo.insert(
|
||||
reactions.map((reaction) => ({
|
||||
id: reaction.id,
|
||||
messageId,
|
||||
oderId: reaction.oderId || null,
|
||||
userId: reaction.userId || null,
|
||||
emoji: reaction.emoji,
|
||||
timestamp: reaction.timestamp
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadMessageReactionsMap(
|
||||
dataSource: DataSource,
|
||||
messageIds: readonly string[]
|
||||
): Promise<Map<string, ReactionPayload[]>> {
|
||||
const groupedReactions = new Map<string, ReactionPayload[]>();
|
||||
|
||||
if (messageIds.length === 0) {
|
||||
return groupedReactions;
|
||||
}
|
||||
|
||||
const rows = await dataSource.getRepository(ReactionEntity).find({
|
||||
where: { messageId: In([...messageIds]) }
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
const reactions = groupedReactions.get(row.messageId) ?? [];
|
||||
|
||||
reactions.push({
|
||||
id: row.id,
|
||||
messageId: row.messageId,
|
||||
oderId: row.oderId ?? '',
|
||||
userId: row.userId ?? '',
|
||||
emoji: row.emoji,
|
||||
timestamp: row.timestamp
|
||||
});
|
||||
groupedReactions.set(row.messageId, reactions);
|
||||
}
|
||||
|
||||
for (const reactions of groupedReactions.values()) {
|
||||
reactions.sort((first, second) => first.timestamp - second.timestamp);
|
||||
}
|
||||
|
||||
return groupedReactions;
|
||||
}
|
||||
|
||||
export async function replaceRoomRelations(
|
||||
manager: EntityManager,
|
||||
roomId: string,
|
||||
options: { channels?: unknown; members?: unknown }
|
||||
): Promise<void> {
|
||||
if (options.channels !== undefined) {
|
||||
const channelRepo = manager.getRepository(RoomChannelEntity);
|
||||
const channels = normalizeRoomChannels(options.channels);
|
||||
|
||||
await channelRepo.delete({ roomId });
|
||||
|
||||
if (channels.length > 0) {
|
||||
await channelRepo.insert(
|
||||
channels.map((channel) => ({
|
||||
roomId,
|
||||
channelId: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
position: channel.position
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.members !== undefined) {
|
||||
const memberRepo = manager.getRepository(RoomMemberEntity);
|
||||
const members = normalizeRoomMembers(options.members);
|
||||
|
||||
await memberRepo.delete({ roomId });
|
||||
|
||||
if (members.length > 0) {
|
||||
await memberRepo.insert(
|
||||
members.map((member) => ({
|
||||
roomId,
|
||||
memberKey: memberKey(member),
|
||||
id: member.id,
|
||||
oderId: member.oderId ?? null,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
avatarUrl: member.avatarUrl ?? null,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
lastSeenAt: member.lastSeenAt
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRoomRelationsMap(
|
||||
dataSource: DataSource,
|
||||
roomIds: readonly string[]
|
||||
): Promise<Map<string, RoomRelationRecord>> {
|
||||
const groupedRelations = new Map<string, RoomRelationRecord>();
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
return groupedRelations;
|
||||
}
|
||||
|
||||
const [channelRows, memberRows] = await Promise.all([
|
||||
dataSource.getRepository(RoomChannelEntity).find({
|
||||
where: { roomId: In([...roomIds]) }
|
||||
}),
|
||||
dataSource.getRepository(RoomMemberEntity).find({
|
||||
where: { roomId: In([...roomIds]) }
|
||||
})
|
||||
]);
|
||||
|
||||
for (const row of channelRows) {
|
||||
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
||||
|
||||
relation.channels.push({
|
||||
id: row.channelId,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
position: row.position
|
||||
});
|
||||
groupedRelations.set(row.roomId, relation);
|
||||
}
|
||||
|
||||
for (const row of memberRows) {
|
||||
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
|
||||
|
||||
relation.members.push({
|
||||
id: row.id,
|
||||
oderId: row.oderId ?? undefined,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
avatarUrl: row.avatarUrl ?? undefined,
|
||||
role: row.role,
|
||||
joinedAt: row.joinedAt,
|
||||
lastSeenAt: row.lastSeenAt
|
||||
});
|
||||
groupedRelations.set(row.roomId, relation);
|
||||
}
|
||||
|
||||
for (const relation of groupedRelations.values()) {
|
||||
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
|
||||
relation.members.sort(compareRoomMembers);
|
||||
}
|
||||
|
||||
return groupedRelations;
|
||||
}
|
||||
Reference in New Issue
Block a user