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