Refacor electron app and add migrations

This commit is contained in:
2026-03-04 01:38:43 +01:00
parent 4e95ae77c5
commit be91b6dfe8
70 changed files with 1824 additions and 923 deletions

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import {
MessageEntity,
UserEntity,
RoomEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
MetaEntity
} from '../../../entities';
export async function handleClearAllData(dataSource: DataSource): Promise<void> {
await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();
await dataSource.getRepository(MetaEntity).clear();
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { ClearRoomMessagesCommand } from '../../types';
export async function handleClearRoomMessages(command: ClearRoomMessagesCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ roomId: command.payload.roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { DeleteAttachmentsForMessageCommand } from '../../types';
export async function handleDeleteAttachmentsForMessage(command: DeleteAttachmentsForMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
await repo.delete({ messageId: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { DeleteMessageCommand } from '../../types';
export async function handleDeleteMessage(command: DeleteMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
await repo.delete({ id: command.payload.messageId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities';
import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { RemoveBanCommand } from '../../types';
export async function handleRemoveBan(command: RemoveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
await repo.delete({ oderId: command.payload.oderId });
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { RemoveReactionCommand } from '../../types';
export async function handleRemoveReaction(command: RemoveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { messageId, userId, emoji } = command.payload;
await repo.delete({ messageId, userId, emoji });
}

View File

@@ -0,0 +1,21 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { SaveAttachmentCommand } from '../../types';
export async function handleSaveAttachment(command: SaveAttachmentCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(AttachmentEntity);
const { attachment } = command.payload;
const entity = repo.create({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage ? 1 : 0,
uploaderPeerId: attachment.uploaderPeerId ?? null,
filePath: attachment.filePath ?? null,
savedPath: attachment.savedPath ?? null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,20 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { SaveBanCommand } from '../../types';
export async function handleSaveBan(command: SaveBanCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(BanEntity);
const { ban } = command.payload;
const entity = repo.create({
oderId: ban.oderId,
roomId: ban.roomId,
userId: ban.userId ?? null,
bannedBy: ban.bannedBy,
displayName: ban.displayName ?? null,
reason: ban.reason ?? null,
expiresAt: ban.expiresAt ?? null,
timestamp: ban.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,24 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { SaveReactionCommand } from '../../types';
export async function handleSaveReaction(command: SaveReactionCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ReactionEntity);
const { reaction } = command.payload;
// Deduplicate: skip if same messageId + userId + emoji already exists
const existing = await repo.findOne({
where: { messageId: reaction.messageId, userId: reaction.userId, emoji: reaction.emoji }
});
if (existing)
return;
const entity = repo.create({
id: reaction.id,
messageId: reaction.messageId,
oderId: reaction.oderId ?? null,
userId: reaction.userId ?? null,
emoji: reaction.emoji,
timestamp: reaction.timestamp
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { SaveRoomCommand } from '../../types';
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload;
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
channels: room.channels != null ? JSON.stringify(room.channels) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,26 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { SaveUserCommand } from '../../types';
export async function handleSaveUser(command: SaveUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { user } = command.payload;
const entity = repo.create({
id: user.id,
oderId: user.oderId ?? null,
username: user.username ?? null,
displayName: user.displayName ?? null,
avatarUrl: user.avatarUrl ?? null,
status: user.status ?? null,
role: user.role ?? null,
joinedAt: user.joinedAt ?? null,
peerId: user.peerId ?? null,
isOnline: user.isOnline ? 1 : 0,
isAdmin: user.isAdmin ? 1 : 0,
isRoomOwner: user.isRoomOwner ? 1 : 0,
voiceState: user.voiceState != null ? JSON.stringify(user.voiceState) : null,
screenShareState: user.screenShareState != null ? JSON.stringify(user.screenShareState) : null
});
await repo.save(entity);
}

View File

@@ -0,0 +1,9 @@
import { DataSource } from 'typeorm';
import { MetaEntity } from '../../../entities';
import { SetCurrentUserIdCommand } from '../../types';
export async function handleSetCurrentUserId(command: SetCurrentUserIdCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MetaEntity);
await repo.save({ key: 'currentUserId', value: command.payload.userId });
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing)
return;
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
if (updates.content !== undefined)
existing.content = updates.content;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
await repo.save(existing);
}

View File

@@ -0,0 +1,29 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull
};
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing)
return;
applyUpdates(existing, updates, ROOM_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,30 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { UpdateUserCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const USER_TRANSFORMS: TransformMap = {
isOnline: boolToInt,
isAdmin: boolToInt,
isRoomOwner: boolToInt,
voiceState: jsonOrNull,
screenShareState: jsonOrNull
};
export async function handleUpdateUser(command: UpdateUserCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(UserEntity);
const { userId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: userId } });
if (!existing)
return;
applyUpdates(existing, updates, USER_TRANSFORMS);
await repo.save(existing);
}

View File

@@ -0,0 +1,32 @@
/** Converts a boolean-like value to SQLite's 0/1 integer representation. */
export const boolToInt = (val: unknown) => (val ? 1 : 0);
/** Serialises an object to a JSON string, or returns null if the value is null/undefined. */
export const jsonOrNull = (val: unknown) => (val != null ? JSON.stringify(val) : null);
/** A map of field names to transform functions that handle special serialisation. */
export type TransformMap = Partial<Record<string, (val: unknown) => unknown>>;
/**
* Applies a partial `updates` object onto an existing entity.
*
* - Fields absent from `updates` (undefined) are skipped entirely.
* - Fields listed in `transforms` are passed through their transform function first.
* - All other fields are written as-is, falling back to null when the value is null/undefined.
*/
export function applyUpdates<T extends object>(
entity: T,
updates: Partial<Record<string, unknown>>,
transforms: TransformMap = {}
): void {
const target = entity as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (value === undefined)
continue;
const transform = transforms[key];
target[key] = transform ? transform(value) : (value ?? null);
}
}

View File

@@ -0,0 +1,59 @@
import { DataSource } from 'typeorm';
import {
CommandType,
CommandTypeKey,
Command,
SaveMessageCommand,
DeleteMessageCommand,
UpdateMessageCommand,
ClearRoomMessagesCommand,
SaveReactionCommand,
RemoveReactionCommand,
SaveUserCommand,
SetCurrentUserIdCommand,
UpdateUserCommand,
SaveRoomCommand,
DeleteRoomCommand,
UpdateRoomCommand,
SaveBanCommand,
RemoveBanCommand,
SaveAttachmentCommand,
DeleteAttachmentsForMessageCommand
} from '../types';
import { handleSaveMessage } from './handlers/saveMessage';
import { handleDeleteMessage } from './handlers/deleteMessage';
import { handleUpdateMessage } from './handlers/updateMessage';
import { handleClearRoomMessages } from './handlers/clearRoomMessages';
import { handleSaveReaction } from './handlers/saveReaction';
import { handleRemoveReaction } from './handlers/removeReaction';
import { handleSaveUser } from './handlers/saveUser';
import { handleSetCurrentUserId } from './handlers/setCurrentUserId';
import { handleUpdateUser } from './handlers/updateUser';
import { handleSaveRoom } from './handlers/saveRoom';
import { handleDeleteRoom } from './handlers/deleteRoom';
import { handleUpdateRoom } from './handlers/updateRoom';
import { handleSaveBan } from './handlers/saveBan';
import { handleRemoveBan } from './handlers/removeBan';
import { handleSaveAttachment } from './handlers/saveAttachment';
import { handleDeleteAttachmentsForMessage } from './handlers/deleteAttachmentsForMessage';
import { handleClearAllData } from './handlers/clearAllData';
export const buildCommandHandlers = (dataSource: DataSource): Record<CommandTypeKey, (command: Command) => Promise<unknown>> => ({
[CommandType.SaveMessage]: (cmd) => handleSaveMessage(cmd as SaveMessageCommand, dataSource),
[CommandType.DeleteMessage]: (cmd) => handleDeleteMessage(cmd as DeleteMessageCommand, dataSource),
[CommandType.UpdateMessage]: (cmd) => handleUpdateMessage(cmd as UpdateMessageCommand, dataSource),
[CommandType.ClearRoomMessages]: (cmd) => handleClearRoomMessages(cmd as ClearRoomMessagesCommand, dataSource),
[CommandType.SaveReaction]: (cmd) => handleSaveReaction(cmd as SaveReactionCommand, dataSource),
[CommandType.RemoveReaction]: (cmd) => handleRemoveReaction(cmd as RemoveReactionCommand, dataSource),
[CommandType.SaveUser]: (cmd) => handleSaveUser(cmd as SaveUserCommand, dataSource),
[CommandType.SetCurrentUserId]: (cmd) => handleSetCurrentUserId(cmd as SetCurrentUserIdCommand, dataSource),
[CommandType.UpdateUser]: (cmd) => handleUpdateUser(cmd as UpdateUserCommand, dataSource),
[CommandType.SaveRoom]: (cmd) => handleSaveRoom(cmd as SaveRoomCommand, dataSource),
[CommandType.DeleteRoom]: (cmd) => handleDeleteRoom(cmd as DeleteRoomCommand, dataSource),
[CommandType.UpdateRoom]: (cmd) => handleUpdateRoom(cmd as UpdateRoomCommand, dataSource),
[CommandType.SaveBan]: (cmd) => handleSaveBan(cmd as SaveBanCommand, dataSource),
[CommandType.RemoveBan]: (cmd) => handleRemoveBan(cmd as RemoveBanCommand, dataSource),
[CommandType.SaveAttachment]: (cmd) => handleSaveAttachment(cmd as SaveAttachmentCommand, dataSource),
[CommandType.DeleteAttachmentsForMessage]: (cmd) => handleDeleteAttachmentsForMessage(cmd as DeleteAttachmentsForMessageCommand, dataSource),
[CommandType.ClearAllData]: () => handleClearAllData(dataSource)
});

103
electron/cqrs/mappers.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Takes TypeORM entity rows and converts them to plain DTO objects
* matching the Angular-side interfaces.
*/
import { MessageEntity } from '../entities/MessageEntity';
import { UserEntity } from '../entities/UserEntity';
import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
export function rowToMessage(row: MessageEntity) {
return {
id: row.id,
roomId: row.roomId,
channelId: row.channelId ?? undefined,
senderId: row.senderId,
senderName: row.senderName,
content: row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
reactions: JSON.parse(row.reactions || '[]') as unknown[],
isDeleted: !!row.isDeleted,
replyToId: row.replyToId ?? undefined
};
}
export function rowToUser(row: UserEntity) {
return {
id: row.id,
oderId: row.oderId ?? '',
username: row.username ?? '',
displayName: row.displayName ?? '',
avatarUrl: row.avatarUrl ?? undefined,
status: row.status ?? 'offline',
role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0,
peerId: row.peerId ?? undefined,
isOnline: !!row.isOnline,
isAdmin: !!row.isAdmin,
isRoomOwner: !!row.isRoomOwner,
voiceState: row.voiceState ? JSON.parse(row.voiceState) : undefined,
screenShareState: row.screenShareState ? JSON.parse(row.screenShareState) : undefined
};
}
export function rowToRoom(row: RoomEntity) {
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
isPrivate: !!row.isPrivate,
createdAt: row.createdAt,
userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: row.channels ? JSON.parse(row.channels) : undefined
};
}
export function rowToReaction(row: ReactionEntity) {
return {
id: row.id,
messageId: row.messageId,
oderId: row.oderId ?? '',
userId: row.userId ?? '',
emoji: row.emoji,
timestamp: row.timestamp
};
}
export function rowToAttachment(row: AttachmentEntity) {
return {
id: row.id,
messageId: row.messageId,
filename: row.filename,
size: row.size,
mime: row.mime,
isImage: !!row.isImage,
uploaderPeerId: row.uploaderPeerId ?? undefined,
filePath: row.filePath ?? undefined,
savedPath: row.savedPath ?? undefined
};
}
export function rowToBan(row: BanEntity) {
return {
oderId: row.oderId,
userId: row.userId ?? '',
roomId: row.roomId,
bannedBy: row.bannedBy,
displayName: row.displayName ?? undefined,
reason: row.reason ?? undefined,
expiresAt: row.expiresAt ?? undefined,
timestamp: row.timestamp
};
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { rowToAttachment } from '../../mappers';
export async function handleGetAllAttachments(dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find();
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,10 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
return rows.map(rowToRoom);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { AttachmentEntity } from '../../../entities';
import { GetAttachmentsForMessageQuery } from '../../types';
import { rowToAttachment } from '../../mappers';
export async function handleGetAttachmentsForMessage(query: GetAttachmentsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(AttachmentEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToAttachment);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { GetBansForRoomQuery } from '../../types';
import { rowToBan } from '../../mappers';
export async function handleGetBansForRoom(query: GetBansForRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId: query.payload.roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.map(rowToBan);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { UserEntity, MetaEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
export async function handleGetCurrentUser(dataSource: DataSource) {
const metaRepo = dataSource.getRepository(MetaEntity);
const metaRow = await metaRepo.findOne({ where: { key: 'currentUserId' } });
if (!metaRow?.value)
return null;
const userRepo = dataSource.getRepository(UserEntity);
const userRow = await userRepo.findOne({ where: { id: metaRow.value } });
return userRow ? rowToUser(userRow) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
return row ? rowToMessage(row) : null;
}

View File

@@ -0,0 +1,17 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, limit = 100, offset = 0 } = query.payload;
const rows = await repo.find({
where: { roomId },
order: { timestamp: 'ASC' },
take: limit,
skip: offset
});
return rows.map(rowToMessage);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { ReactionEntity } from '../../../entities';
import { GetReactionsForMessageQuery } from '../../types';
import { rowToReaction } from '../../mappers';
export async function handleGetReactionsForMessage(query: GetReactionsForMessageQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ReactionEntity);
const rows = await repo.find({ where: { messageId: query.payload.messageId } });
return rows.map(rowToReaction);
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });
return row ? rowToRoom(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { GetUserQuery } from '../../types';
import { rowToUser } from '../../mappers';
export async function handleGetUser(query: GetUserQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const row = await repo.findOne({ where: { id: query.payload.userId } });
return row ? rowToUser(row) : null;
}

View File

@@ -0,0 +1,11 @@
import { DataSource } from 'typeorm';
import { UserEntity } from '../../../entities';
import { rowToUser } from '../../mappers';
/** Returns all stored users (room filtering not applicable in this schema). */
export async function handleGetUsersByRoom(dataSource: DataSource) {
const repo = dataSource.getRepository(UserEntity);
const rows = await repo.find();
return rows.map(rowToUser);
}

View File

@@ -0,0 +1,16 @@
import { DataSource } from 'typeorm';
import { BanEntity } from '../../../entities';
import { IsUserBannedQuery } from '../../types';
export async function handleIsUserBanned(query: IsUserBannedQuery, dataSource: DataSource): Promise<boolean> {
const repo = dataSource.getRepository(BanEntity);
const now = Date.now();
const { userId, roomId } = query.payload;
const rows = await repo
.createQueryBuilder('ban')
.where('ban.roomId = :roomId', { roomId })
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
.getMany();
return rows.some((row) => row.oderId === userId);
}

View File

@@ -0,0 +1,41 @@
import { DataSource } from 'typeorm';
import {
QueryType,
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
GetRoomQuery,
GetBansForRoomQuery,
IsUserBannedQuery,
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
import { handleGetCurrentUser } from './handlers/getCurrentUser';
import { handleGetUsersByRoom } from './handlers/getUsersByRoom';
import { handleGetRoom } from './handlers/getRoom';
import { handleGetAllRooms } from './handlers/getAllRooms';
import { handleGetBansForRoom } from './handlers/getBansForRoom';
import { handleIsUserBanned } from './handlers/isUserBanned';
import { handleGetAttachmentsForMessage } from './handlers/getAttachmentsForMessage';
import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
[QueryType.GetCurrentUser]: () => handleGetCurrentUser(dataSource),
[QueryType.GetUsersByRoom]: () => handleGetUsersByRoom(dataSource),
[QueryType.GetRoom]: (query) => handleGetRoom(query as GetRoomQuery, dataSource),
[QueryType.GetAllRooms]: () => handleGetAllRooms(dataSource),
[QueryType.GetBansForRoom]: (query) => handleGetBansForRoom(query as GetBansForRoomQuery, dataSource),
[QueryType.IsUserBanned]: (query) => handleIsUserBanned(query as IsUserBannedQuery, dataSource),
[QueryType.GetAttachmentsForMessage]: (query) => handleGetAttachmentsForMessage(query as GetAttachmentsForMessageQuery, dataSource),
[QueryType.GetAllAttachments]: () => handleGetAllAttachments(dataSource)
});

197
electron/cqrs/types.ts Normal file
View File

@@ -0,0 +1,197 @@
/* ------------------------------------------------------------------ */
/* CQRS type definitions for the MetoYou electron main process. */
/* Commands mutate state; queries read state. */
/* ------------------------------------------------------------------ */
// --------------- Command types ---------------
export const CommandType = {
SaveMessage: 'save-message',
DeleteMessage: 'delete-message',
UpdateMessage: 'update-message',
ClearRoomMessages: 'clear-room-messages',
SaveReaction: 'save-reaction',
RemoveReaction: 'remove-reaction',
SaveUser: 'save-user',
SetCurrentUserId: 'set-current-user-id',
UpdateUser: 'update-user',
SaveRoom: 'save-room',
DeleteRoom: 'delete-room',
UpdateRoom: 'update-room',
SaveBan: 'save-ban',
RemoveBan: 'remove-ban',
SaveAttachment: 'save-attachment',
DeleteAttachmentsForMessage: 'delete-attachments-for-message',
ClearAllData: 'clear-all-data'
} as const;
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
// --------------- Query types ---------------
export const QueryType = {
GetMessages: 'get-messages',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
GetCurrentUser: 'get-current-user',
GetUsersByRoom: 'get-users-by-room',
GetRoom: 'get-room',
GetAllRooms: 'get-all-rooms',
GetBansForRoom: 'get-bans-for-room',
IsUserBanned: 'is-user-banned',
GetAttachmentsForMessage: 'get-attachments-for-message',
GetAllAttachments: 'get-all-attachments'
} as const;
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
// --------------- Payload interfaces ---------------
export interface MessagePayload {
id: string;
roomId: string;
channelId?: string;
senderId: string;
senderName: string;
content: string;
timestamp: number;
editedAt?: number;
reactions?: ReactionPayload[];
isDeleted?: boolean;
replyToId?: string;
}
export interface ReactionPayload {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
export interface UserPayload {
id: string;
oderId?: string;
username?: string;
displayName?: string;
avatarUrl?: string;
status?: string;
role?: string;
joinedAt?: number;
peerId?: string;
isOnline?: boolean;
isAdmin?: boolean;
isRoomOwner?: boolean;
voiceState?: unknown;
screenShareState?: unknown;
}
export interface RoomPayload {
id: string;
name: string;
description?: string;
topic?: string;
hostId: string;
password?: string;
isPrivate?: boolean;
createdAt: number;
userCount?: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
permissions?: unknown;
channels?: unknown[];
}
export interface BanPayload {
oderId: string;
roomId: string;
userId?: string;
bannedBy: string;
displayName?: string;
reason?: string;
expiresAt?: number;
timestamp: number;
}
export interface AttachmentPayload {
id: string;
messageId: string;
filename: string;
size: number;
mime: string;
isImage?: boolean;
uploaderPeerId?: string;
filePath?: string;
savedPath?: string;
}
// --------------- Command interfaces ---------------
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
export interface ClearRoomMessagesCommand { type: typeof CommandType.ClearRoomMessages; payload: { roomId: string } }
export interface SaveReactionCommand { type: typeof CommandType.SaveReaction; payload: { reaction: ReactionPayload } }
export interface RemoveReactionCommand { type: typeof CommandType.RemoveReaction; payload: { messageId: string; userId: string; emoji: string } }
export interface SaveUserCommand { type: typeof CommandType.SaveUser; payload: { user: UserPayload } }
export interface SetCurrentUserIdCommand { type: typeof CommandType.SetCurrentUserId; payload: { userId: string } }
export interface UpdateUserCommand { type: typeof CommandType.UpdateUser; payload: { userId: string; updates: Partial<UserPayload> } }
export interface SaveRoomCommand { type: typeof CommandType.SaveRoom; payload: { room: RoomPayload } }
export interface DeleteRoomCommand { type: typeof CommandType.DeleteRoom; payload: { roomId: string } }
export interface UpdateRoomCommand { type: typeof CommandType.UpdateRoom; payload: { roomId: string; updates: Partial<RoomPayload> } }
export interface SaveBanCommand { type: typeof CommandType.SaveBan; payload: { ban: BanPayload } }
export interface RemoveBanCommand { type: typeof CommandType.RemoveBan; payload: { oderId: string } }
export interface SaveAttachmentCommand { type: typeof CommandType.SaveAttachment; payload: { attachment: AttachmentPayload } }
export interface DeleteAttachmentsForMessageCommand { type: typeof CommandType.DeleteAttachmentsForMessage; payload: { messageId: string } }
export interface ClearAllDataCommand { type: typeof CommandType.ClearAllData; payload: Record<string, never> }
export type Command =
| SaveMessageCommand
| DeleteMessageCommand
| UpdateMessageCommand
| ClearRoomMessagesCommand
| SaveReactionCommand
| RemoveReactionCommand
| SaveUserCommand
| SetCurrentUserIdCommand
| UpdateUserCommand
| SaveRoomCommand
| DeleteRoomCommand
| UpdateRoomCommand
| SaveBanCommand
| RemoveBanCommand
| SaveAttachmentCommand
| DeleteAttachmentsForMessageCommand
| ClearAllDataCommand;
// --------------- Query interfaces ---------------
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
export interface GetCurrentUserQuery { type: typeof QueryType.GetCurrentUser; payload: Record<string, never> }
export interface GetUsersByRoomQuery { type: typeof QueryType.GetUsersByRoom; payload: { roomId: string } }
export interface GetRoomQuery { type: typeof QueryType.GetRoom; payload: { roomId: string } }
export interface GetAllRoomsQuery { type: typeof QueryType.GetAllRooms; payload: Record<string, never> }
export interface GetBansForRoomQuery { type: typeof QueryType.GetBansForRoom; payload: { roomId: string } }
export interface IsUserBannedQuery { type: typeof QueryType.IsUserBanned; payload: { userId: string; roomId: string } }
export interface GetAttachmentsForMessageQuery { type: typeof QueryType.GetAttachmentsForMessage; payload: { messageId: string } }
export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachments; payload: Record<string, never> }
export type Query =
| GetMessagesQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery
| GetCurrentUserQuery
| GetUsersByRoomQuery
| GetRoomQuery
| GetAllRoomsQuery
| GetBansForRoomQuery
| IsUserBannedQuery
| GetAttachmentsForMessageQuery
| GetAllAttachmentsQuery;