import { SendSupportMessageResult, SupportAuthorRole, SupportConversationDto, SupportConversationListResult, SupportMessageDto, } from "@edr/types"; import { ForbiddenException, Injectable, NotFoundException, } from "@nestjs/common"; import { QueryFailedError } from "typeorm"; import { CompaniesService } from "../companies/companies.service"; import { ExternalProfileRepository } from "../companies/external-profile.repository"; import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto"; import { SupportConversation } from "./entities/support-conversation.entity"; import { SupportMessage } from "./entities/support-message.entity"; import { SupportChatGateway } from "./support-chat.gateway"; import { SupportConversationRepository } from "./support-conversation.repository"; import { SupportMessageRepository } from "./support-message.repository"; interface CustomerContext { companyId: string; companyName?: string | null; authorName?: string | null; } /** Postgres unique_violation — the one-thread-per-company index fired. */ const PG_UNIQUE_VIOLATION = "23505"; @Injectable() export class SupportChatService { constructor( private readonly conversations: SupportConversationRepository, private readonly messages: SupportMessageRepository, private readonly gateway: SupportChatGateway, private readonly externalProfiles: ExternalProfileRepository, private readonly companies: CompaniesService, ) {} // ---- customer (portal) ------------------------------------------------- /** * The caller's company thread, or null if nobody has spoken yet. Deliberately * does *not* create: opening the widget shouldn't push an empty thread into * the agent inbox. Creation happens on the first message. */ async getCustomerConversation( userId: string, ): Promise { const ctx = await this.resolveCustomer(userId); const conversation = await this.conversations.findByCompanyId(ctx.companyId); if (!conversation) return null; const unread = await this.messages.unreadCountsByConversation( [conversation.id], SupportAuthorRole.CUSTOMER, ); return this.toConversationDto( conversation, unread.get(conversation.id) ?? 0, ); } async getCustomerMessages(userId: string): Promise { const ctx = await this.resolveCustomer(userId); const conversation = await this.conversations.findByCompanyId(ctx.companyId); if (!conversation) return []; return this.listMessages(conversation.id); } /** Send as the customer, opening the thread if this is the first message. */ async sendAsCustomer( userId: string, body: string, ): Promise { const ctx = await this.resolveCustomer(userId); const conversation = await this.getOrCreate( ctx.companyId, ctx.companyName, userId, ); const { conversation: updated, message } = await this.appendMessage( conversation, userId, SupportAuthorRole.CUSTOMER, body, ctx.authorName, ); return { conversation: this.toConversationDto(updated, 0), message: this.toMessageDto(message), }; } async markCustomerRead(userId: string): Promise<{ unreadCount: number }> { const ctx = await this.resolveCustomer(userId); const conversation = await this.conversations.findByCompanyId(ctx.companyId); if (conversation) { await this.conversations.update(conversation.id, { customerLastReadAt: new Date(), }); } return this.unreadCount(SupportAuthorRole.CUSTOMER, userId); } // ---- agent (backoffice) ------------------------------------------------ async listForAgents( query: ListConversationsQueryDto, ): Promise { const [rows, count] = await this.conversations.listAll( SupportAuthorRole.AGENT, query, ); return this.buildListResult(rows, count, SupportAuthorRole.AGENT); } /** * Open (or reuse) the thread with a company so an agent can start chatting. * Idempotent — clicking a company that already has a thread just returns it. */ async startWithCompany(companyId: string): Promise { const company = await this.companies.findCompanyById(companyId); const existing = await this.conversations.findByCompanyId(companyId); const conversation = existing ?? (await this.getOrCreate(companyId, company.name, null)); const unread = await this.messages.unreadCountsByConversation( [conversation.id], SupportAuthorRole.AGENT, ); const dto = this.toConversationDto( conversation, unread.get(conversation.id) ?? 0, ); if (!existing) { // Surface the new thread in every agent's inbox right away. this.gateway.emitConversationUpdated(conversation.companyId, dto); } return dto; } async sendAsAgent( conversationId: string, userId: string, body: string, ): Promise { const conversation = await this.requireConversation(conversationId); const { message } = await this.appendMessage( conversation, userId, SupportAuthorRole.AGENT, body, ); return this.toMessageDto(message); } async markAgentRead( conversationId: string, userId: string, ): Promise<{ unreadCount: number }> { await this.requireConversation(conversationId); await this.conversations.update(conversationId, { agentLastReadAt: new Date(), }); return this.unreadCount(SupportAuthorRole.AGENT, userId); } // ---- shared ------------------------------------------------------------ /** * A thread's messages. Pass `asCustomerUserId` to enforce that the caller's * company owns it (portal route); omit for agents, who see every thread. */ async getMessages( conversationId: string, asCustomerUserId?: string, ): Promise { const conversation = await this.requireConversation(conversationId); if (asCustomerUserId) { await this.assertCustomerOwns(conversation, asCustomerUserId); } return this.listMessages(conversationId); } async unreadCount( side: SupportAuthorRole, userId: string, ): Promise<{ unreadCount: number }> { if (side === SupportAuthorRole.CUSTOMER) { const ctx = await this.resolveCustomer(userId); return { unreadCount: await this.messages.countUnreadConversations( side, ctx.companyId, ), }; } return { unreadCount: await this.messages.countUnreadConversations(side) }; } // ---- internals --------------------------------------------------------- /** * Fetch the company's thread or open it. Two first-messages can race here, so * we let the unique index arbitrate and re-read the winner rather than * locking — the loser's insert is the only wasted work. */ private async getOrCreate( companyId: string, companyName: string | null | undefined, createdByUserId: string | null, ): Promise { const existing = await this.conversations.findByCompanyId(companyId); if (existing) return existing; try { return await this.conversations.create({ companyId, companyName: companyName ?? null, createdByUserId, }); } catch (error) { if ( error instanceof QueryFailedError && (error as QueryFailedError & { code?: string }).code === PG_UNIQUE_VIOLATION ) { const winner = await this.conversations.findByCompanyId(companyId); if (winner) return winner; } throw error; } } private async listMessages( conversationId: string, ): Promise { const rows = await this.messages.listByConversation(conversationId); return rows.map((m) => this.toMessageDto(m)); } /** Persist a message, bump the conversation's denormalized fields, emit live. */ private async appendMessage( conversation: SupportConversation, userId: string, role: SupportAuthorRole, body: string, authorName?: string | null, ): Promise<{ conversation: SupportConversation; message: SupportMessage }> { const message = await this.messages.create({ conversationId: conversation.id, authorUserId: userId, authorRole: role, authorName: authorName ?? null, body, }); conversation.lastMessageAt = message.createdAt; conversation.lastMessagePreview = body.slice(0, 280); conversation.lastMessageAuthorRole = role; await this.conversations.update(conversation.id, { lastMessageAt: conversation.lastMessageAt, lastMessagePreview: conversation.lastMessagePreview, lastMessageAuthorRole: role, }); const dto = this.toConversationDto(conversation, 0); this.gateway.emitMessage( conversation.companyId, dto, this.toMessageDto(message), ); return { conversation, message }; } private async buildListResult( rows: SupportConversation[], count: number, side: SupportAuthorRole, companyId?: string, ): Promise { const unreadMap = await this.messages.unreadCountsByConversation( rows.map((r) => r.id), side, ); const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0), ); const unreadCount = await this.messages.countUnreadConversations( side, companyId, ); return { items, count, unreadCount }; } private async resolveCustomer(userId: string): Promise { const profile = await this.externalProfiles.findByUserId(userId); if (!profile?.companyId) { throw new ForbiddenException( "No company profile is linked to this account.", ); } const name = [profile.firstName, profile.lastName] .filter(Boolean) .join(" ") .trim(); return { companyId: profile.companyId, companyName: profile.company?.name ?? null, authorName: name || null, }; } private async assertCustomerOwns( conversation: SupportConversation, userId: string, ): Promise { const ctx = await this.resolveCustomer(userId); if (conversation.companyId !== ctx.companyId) { throw new ForbiddenException("This conversation belongs to another company."); } return ctx; } private async requireConversation(id: string): Promise { const conversation = await this.conversations.findById(id); if (!conversation) { throw new NotFoundException("Conversation not found."); } return conversation; } private toConversationDto( c: SupportConversation, unreadCount: number, ): SupportConversationDto { return { id: c.id, companyId: c.companyId, companyName: c.companyName ?? null, createdByUserId: c.createdByUserId ?? null, lastMessageAt: c.lastMessageAt ? new Date(c.lastMessageAt).toISOString() : null, lastMessagePreview: c.lastMessagePreview ?? null, lastMessageAuthorRole: c.lastMessageAuthorRole ?? null, unreadCount, createdAt: new Date(c.createdAt).toISOString(), updatedAt: new Date(c.updatedAt).toISOString(), }; } private toMessageDto(m: SupportMessage): SupportMessageDto { return { id: m.id, conversationId: m.conversationId, authorUserId: m.authorUserId, authorRole: m.authorRole, authorName: m.authorName ?? null, body: m.body, createdAt: new Date(m.createdAt).toISOString(), }; } }