import type { Conversation, ConversationMember, ConversationNewEvent, ConversationMembershipEvent, ReadReceiptEvent, } from '@altricade/core'; import { userChannel, EventType } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; import type { Publisher } from '../../shared/publisher'; import { toPublicUser } from '../users'; import type { UsersRepository } from '../users'; import type { ConversationsRepository, DeliveryInfo } from './conversations.repository'; import type { ReadStateRepository } from './read-state.repository'; import type { Deliver } from './delivery'; import { toConversation, toMember, memberToPublicUser } from './conversations.mapper'; export interface ConversationsServiceDeps { conversations: ConversationsRepository; users: UsersRepository; readState: ReadStateRepository; deliver: Deliver; publish: Publisher; } export interface ConversationsService { createDirect(actorId: string, username: string): Promise; createGroup(actorId: string, title: string, memberUsernames: string[]): Promise; list(userId: string): Promise; get(conversationId: string, userId: string): Promise; listMembers(conversationId: string, userId: string): Promise; addMember(conversationId: string, actorId: string, username: string): Promise; removeMember(conversationId: string, actorId: string, targetUserId: string): Promise; isMember(conversationId: string, userId: string): Promise; getDeliveryInfo(conversationId: string): Promise; markRead(conversationId: string, userId: string, seq: number): Promise; } export const createConversationsService = ( deps: ConversationsServiceDeps, ): ConversationsService => { const { conversations, users, readState, deliver, publish } = 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 assertOwner = async (conversationId: string, userId: string): Promise => { if ((await conversations.getRole(conversationId, userId)) !== 'owner') { throw new HttpError(403, 'not_owner', 'Only the group owner can do this'); } }; const conversationNew = (conversation: Conversation): ConversationNewEvent => ({ type: EventType.ConversationNew, conversation, }); const membership = ( action: ConversationMembershipEvent['action'], conversationId: string, userId: string, ): ConversationMembershipEvent => ({ type: EventType.ConversationMembership, action, conversationId, userId }); return { createDirect: async (actorId, username) => { const target = await users.findByUsername(username); if (target === undefined) { throw new HttpError(404, 'user_not_found', 'No such user'); } if (target.id === actorId) { throw new HttpError(400, 'invalid_target', 'Cannot start a direct chat with yourself'); } const targetPublic = toPublicUser(target); const { conversation, created } = await conversations.findOrCreateDirect(actorId, target.id); if (created) { const actorRow = await users.findById(actorId); const actorPublic = actorRow === undefined ? null : toPublicUser(actorRow); await publish( userChannel(target.id), conversationNew(toConversation(conversation, actorPublic)), ); await publish( userChannel(actorId), conversationNew(toConversation(conversation, targetPublic)), ); } return toConversation(conversation, targetPublic); }, createGroup: async (actorId, title, memberUsernames) => { const memberIds: string[] = []; for (const username of memberUsernames) { const found = await users.findByUsername(username); if (found !== undefined && found.id !== actorId) { memberIds.push(found.id); } } const conversation = toConversation( await conversations.createGroup(title, actorId, memberIds), null, ); for (const userId of [actorId, ...memberIds]) { await publish(userChannel(userId), conversationNew(conversation)); } return conversation; }, list: async (userId) => { const rows = await conversations.listForUser(userId); const directIds = rows.filter((row) => row.type === 'direct').map((row) => row.id); const peers = await conversations.peersForDirect(userId, directIds); const unread = await readState.unreadCounts(userId); return rows.map((row) => { const count = unread.get(row.id) ?? 0; if (row.type !== 'direct') { return toConversation(row, null, count); } const peer = peers.get(row.id); return toConversation(row, peer === undefined ? null : memberToPublicUser(peer), count); }); }, get: async (conversationId, userId) => { await assertMember(conversationId, userId); const conversation = await conversations.findById(conversationId); if (conversation === undefined) { throw new HttpError(404, 'not_found', 'Conversation not found'); } const count = await readState.unreadFor(conversationId, userId); if (conversation.type !== 'direct') { return toConversation(conversation, null, count); } const peer = await conversations.getPeer(conversationId, userId); return toConversation( conversation, peer === undefined ? null : memberToPublicUser(peer), count, ); }, listMembers: async (conversationId, userId) => { await assertMember(conversationId, userId); return (await conversations.listMembers(conversationId)).map(toMember); }, addMember: async (conversationId, actorId, username) => { await assertOwner(conversationId, actorId); const target = await users.findByUsername(username); if (target === undefined) { throw new HttpError(404, 'user_not_found', 'No such user'); } await conversations.addMember(conversationId, target.id, 'member'); const conversation = await conversations.findById(conversationId); if (conversation !== undefined) { await publish(userChannel(target.id), conversationNew(toConversation(conversation, null))); } await publish(userChannel(target.id), membership('added', conversationId, target.id)); return (await conversations.listMembers(conversationId)).map(toMember); }, removeMember: async (conversationId, actorId, targetUserId) => { if (actorId !== targetUserId) { await assertOwner(conversationId, actorId); } await conversations.removeMember(conversationId, targetUserId); await publish(userChannel(targetUserId), membership('removed', conversationId, targetUserId)); }, isMember: (conversationId, userId) => conversations.isMember(conversationId, userId), getDeliveryInfo: (conversationId) => conversations.getDeliveryInfo(conversationId), markRead: async (conversationId, userId, seq) => { await assertMember(conversationId, userId); await readState.setRead(conversationId, userId, seq); const event: ReadReceiptEvent = { type: EventType.ReadReceipt, conversationId, userId, seq }; await deliver(conversationId, event); }, }; }; declare module 'fastify' { interface FastifyInstance { conversationsService: ConversationsService; } }