import { EventType } from '@altricade/core'; import type { Message, MessageNewEvent, MessageEditEvent, MessageDeleteEvent, ReactionEvent, SendMessageBody, } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; import type { ConversationsRepository } from '../conversations'; import type { Deliver } from '../conversations'; import type { MessagesRepository } from './messages.repository'; import { toMessage } from './messages.mapper'; export interface MessagesServiceDeps { messages: MessagesRepository; conversations: ConversationsRepository; deliver: Deliver; mediaDownloadUrl: (objectKey: string) => Promise; } export interface SentMessage { message: Message; created: boolean; } export interface MessagesService { send(conversationId: string, senderId: string, input: SendMessageBody): Promise; history( conversationId: string, userId: string, beforeSeq: number | null, limit: number, ): Promise; edit( conversationId: string, messageId: string, userId: string, content: string, ): Promise; remove(conversationId: string, messageId: string, userId: string): Promise; addReaction( conversationId: string, messageId: string, userId: string, emoji: string, ): Promise; removeReaction( conversationId: string, messageId: string, userId: string, emoji: string, ): Promise; mediaUrl(conversationId: string, messageId: string, userId: string): Promise; } export const createMessagesService = (deps: MessagesServiceDeps): MessagesService => { const { messages, conversations, deliver, mediaDownloadUrl } = deps; const assertMember = async (conversationId: string, userId: string): Promise => { if (!(await conversations.isMember(conversationId, userId))) { throw new HttpError(403, 'not_a_member', 'You are not a member of this conversation'); } }; const assertMessageIn = async (conversationId: string, messageId: string): Promise => { if ((await messages.getConversationId(messageId)) !== conversationId) { throw new HttpError(404, 'not_found', 'Message not found'); } }; const loadMessage = async (messageId: string, userId: string): Promise => { const row = await messages.getWithSenderById(messageId); if (row === undefined) { throw new HttpError(404, 'not_found', 'Message not found'); } const reactions = await messages.reactionsFor([messageId], userId); return toMessage(row, reactions.get(messageId) ?? []); }; return { send: async (conversationId, senderId, input) => { await assertMember(conversationId, senderId); const inserted = await messages.insert({ conversationId, senderId, clientMsgId: input.clientMsgId, content: input.content ?? '', contentType: input.contentType ?? 'text', encryption: input.encryption ?? null, mediaKey: input.mediaKey ?? null, media: input.media ?? null, }); const id = inserted?.id ?? (await messages.findIdByDedupe(conversationId, senderId, input.clientMsgId)); if (id === undefined) { throw new HttpError(500, 'internal_error', 'Message could not be persisted'); } const row = await messages.getWithSenderById(id); if (row === undefined) { throw new HttpError(500, 'internal_error', 'Message not found after insert'); } const message = toMessage(row, []); if (inserted !== undefined) { await conversations.touchLastMessage(conversationId); const event: MessageNewEvent = { type: EventType.MessageNew, message }; await deliver(conversationId, event); } return { message, created: inserted !== undefined }; }, history: async (conversationId, userId, beforeSeq, limit) => { await assertMember(conversationId, userId); const rows = await messages.listHistory(conversationId, beforeSeq, limit); const reactions = await messages.reactionsFor( rows.map((row) => row.id), userId, ); return rows.map((row) => toMessage(row, reactions.get(row.id) ?? [])); }, edit: async (conversationId, messageId, userId, content) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); const edited = await messages.editContent(messageId, userId, content); if (edited === undefined) { throw new HttpError(403, 'not_editable', 'You can only edit your own messages'); } const message = await loadMessage(messageId, userId); const event: MessageEditEvent = { type: EventType.MessageEdit, message }; await deliver(conversationId, event); return message; }, remove: async (conversationId, messageId, userId) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); const deleted = await messages.softDelete(messageId, userId); if (deleted === undefined) { throw new HttpError(403, 'not_deletable', 'You can only delete your own messages'); } const event: MessageDeleteEvent = { type: EventType.MessageDelete, conversationId, messageId }; await deliver(conversationId, event); }, addReaction: async (conversationId, messageId, userId, emoji) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); await messages.addReaction(messageId, userId, emoji); const event: ReactionEvent = { type: EventType.ReactionAdd, conversationId, messageId, emoji, userId, }; await deliver(conversationId, event); }, removeReaction: async (conversationId, messageId, userId, emoji) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); await messages.removeReaction(messageId, userId, emoji); const event: ReactionEvent = { type: EventType.ReactionRemove, conversationId, messageId, emoji, userId, }; await deliver(conversationId, event); }, mediaUrl: async (conversationId, messageId, userId) => { await assertMember(conversationId, userId); await assertMessageIn(conversationId, messageId); const row = await messages.getWithSenderById(messageId); const mediaKey = row?.media_key ?? null; if (mediaKey === null) { throw new HttpError(404, 'not_found', 'No media on this message'); } return mediaDownloadUrl(mediaKey); }, }; }; declare module 'fastify' { interface FastifyInstance { messagesService: MessagesService; } }