import { isSupportAttachmentAllowed, SendSupportMessageResult, SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE, SUPPORT_ATTACHMENT_RESOURCE, SupportAttachmentDto, SupportAuthorRole, SupportConversationDto, SupportConversationListResult, SupportMessageDto, SupportMessageListResult, } from "@edr/types"; import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from "@nestjs/common"; import { QueryFailedError } from "typeorm"; import { BackofficeService } from "../backoffice/backoffice.service"; import { CompaniesService } from "../companies/companies.service"; import { ExternalProfileRepository } from "../companies/external-profile.repository"; import { FileRecord } from "../files/entities/file.entity"; import { FilesService } from "../files/files.service"; import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto"; import { ListMessagesQueryDto, SUPPORT_MESSAGES_DEFAULT_LIMIT, } from "./dto/list-messages-query.dto"; import { SupportConversation } from "./entities/support-conversation.entity"; import { SupportMessage } from "./entities/support-message.entity"; import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor"; 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"; /** Stand-in preview for a message that is nothing but files. */ const ATTACHMENT_ONLY_PREVIEW = "📎"; @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, private readonly files: FilesService, private readonly backoffice: BackofficeService, ) {} // ---- 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, query: ListMessagesQueryDto = {}, ): Promise { const ctx = await this.resolveCustomer(userId); const conversation = await this.conversations.findByCompanyId( ctx.companyId, ); if (!conversation) return { items: [], nextCursor: null }; return this.listMessages(conversation.id, query); } /** Send as the customer, opening the thread if this is the first message. */ async sendAsCustomer( userId: string, body: string | undefined, attachments: Express.Multer.File[] = [], ): 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, attachments, ); return { conversation: this.toConversationDto(updated, 0), message: 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 | undefined, attachments: Express.Multer.File[] = [], ): Promise { const conversation = await this.requireConversation(conversationId); const { message } = await this.appendMessage( conversation, userId, SupportAuthorRole.AGENT, body, undefined, attachments, ); return 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 ------------------------------------------------------------ /** * One page of a thread's messages, newest page first. Pass `asCustomerUserId` * to enforce that the caller's company owns it (portal route); omit for * agents, who see every thread. */ async getMessages( conversationId: string, query: ListMessagesQueryDto = {}, asCustomerUserId?: string, ): Promise { const conversation = await this.requireConversation(conversationId); if (asCustomerUserId) { await this.assertCustomerOwns(conversation, asCustomerUserId); } return this.listMessages(conversationId, query); } /** * May `userId` read the message that a chat attachment hangs off? Backstop for * the file-download route, which otherwise streams any file to any * authenticated caller who knows its UUID. * * Backoffice staff see every thread (they work a shared inbox); a portal user * sees only their own company's. Fails **closed** — an unresolvable message, * conversation, or staff list denies rather than falls through, since the * caller uses this to decide whether to hand over raw bytes. */ async canUserAccessMessage( messageId: string, userId: string, ): Promise { const message = await this.messages.findById(messageId); if (!message) return false; const conversation = await this.conversations.findById( message.conversationId, ); if (!conversation) return false; try { const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds(); if (staffIds.includes(userId)) return true; } catch { // Staff lookup is best-effort for room-joining in the gateway, but here it // gates bytes: on failure fall through to the (stricter) company check // rather than assuming staff. } const profile = await this.externalProfiles.findByUserId(userId); return Boolean( profile?.companyId && profile.companyId === conversation.companyId, ); } 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, query: ListMessagesQueryDto, ): Promise { const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT; const before = query.before ? decodeMessageCursor(query.before) : undefined; // The repo returns newest-first and over-fetches by one to probe for a // further page. const rows = await this.messages.listByConversation( conversationId, limit, before, ); const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const oldest = page[page.length - 1]; const nextCursor = hasMore && oldest ? encodeMessageCursor(oldest.id) : null; // Flip to oldest-first so the client can prepend a page as one block. const items = await this.toMessageDtos([...page].reverse()); return { items, nextCursor }; } /** * Persist a message (plus any attachments), bump the conversation's * denormalized fields, emit live. * * Files are validated *before* the row is written: a rejected upload should * leave no message behind, and a half-uploaded batch is worse than none. */ private async appendMessage( conversation: SupportConversation, userId: string, role: SupportAuthorRole, body: string | undefined, authorName?: string | null, attachments: Express.Multer.File[] = [], ): Promise<{ conversation: SupportConversation; message: SupportMessageDto; }> { const text = (body ?? "").trim(); this.assertSendable(text, attachments); const message = await this.messages.create({ conversationId: conversation.id, authorUserId: userId, authorRole: role, authorName: authorName ?? null, // NULL, not "", so "this message has no text" is representable rather than // inferred. The DTO flattens it back to "" for rendering. body: text || null, }); // The row has to exist before the files, since each one is stored against // `resourceId = message.id`. That leaves a window: if a upload fails here, // the message is already committed. Undo it rather than leave the thread // with a permanently blank bubble — there is no delete flow, so an orphan // would be unremovable, and an attachment-only message that lost its files // has no content at all. let stored: FileRecord[]; try { stored = await Promise.all( attachments.map((file) => this.files.upload({ resourceId: message.id, resource: SUPPORT_ATTACHMENT_RESOURCE, code: "attachment", file, }), ), ); } catch (error) { await this.messages.softDelete(message.id); throw error; } conversation.lastMessageAt = message.createdAt; conversation.lastMessagePreview = this.buildPreview(text, stored); conversation.lastMessageAuthorRole = role; await this.conversations.update(conversation.id, { lastMessageAt: conversation.lastMessageAt, lastMessagePreview: conversation.lastMessagePreview, lastMessageAuthorRole: role, }); const messageDto = await this.toMessageDto(message, stored); const dto = this.toConversationDto(conversation, 0); this.gateway.emitMessage(conversation.companyId, dto, messageDto); return { conversation, message: messageDto }; } /** * Guard the chat-specific upload rules. These are tighter than * `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set), * which exist for scanned business documents — chat files are pushed at * another human, so the allowlist is narrower and SVG is excluded outright. */ private assertSendable( text: string, attachments: Express.Multer.File[], ): void { if (!text && attachments.length === 0) { throw new BadRequestException( "A message needs text or at least one attachment.", ); } if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) { throw new BadRequestException( `At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`, ); } for (const file of attachments) { if (!isSupportAttachmentAllowed(file.mimetype)) { throw new BadRequestException( `Unsupported attachment type: ${file.mimetype}`, ); } if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) { throw new BadRequestException( `"${file.originalname}" exceeds the ${ SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024) }MB attachment limit.`, ); } } } /** Inbox preview line — falls back to the filenames when there's no text. */ private buildPreview(text: string, attachments: FileRecord[]): string { if (text) return text.slice(0, 280); if (attachments.length === 1) { return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280); } return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`; } 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(), }; } /** Hydrate + map a page of messages, batching the attachment lookup. */ private async toMessageDtos( rows: SupportMessage[], ): Promise { if (rows.length === 0) return []; const grouped = await this.files.findByResourceIdsGrouped( rows.map((r) => r.id), SUPPORT_ATTACHMENT_RESOURCE, ); return Promise.all( rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])), ); } private async toMessageDto( m: SupportMessage, attachments: FileRecord[], ): Promise { return { id: m.id, conversationId: m.conversationId, authorUserId: m.authorUserId, authorRole: m.authorRole, authorName: m.authorName ?? null, body: m.body ?? "", attachments: attachments.map((a) => this.toAttachmentDto(a)), createdAt: new Date(m.createdAt).toISOString(), }; } /** * Where the browser fetches the bytes: the API's own ownership-checked stream * route, NOT a presigned MinIO URL. * * Presigned object URLs are not reachable from the browser in this deployment * — the same reason every other file in the app streams through * `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper * on the web side, and the minio-js port-443 signature quirk noted there). Chat * attachments stream through `GET /api/support/attachments/:id`, which runs the * same-company / staff ownership check before serving a byte. * * A root-relative path; the web app prepends its API origin. The `` sends * the `auth-token` cookie automatically (same-site across dev ports), which is * how the guard authenticates a request that can't carry a bearer header. */ private toAttachmentDto(f: FileRecord): SupportAttachmentDto { return { id: f.id, name: f.name, mimeType: f.mimeType, size: f.size, url: `/api/support/attachments/${f.id}`, }; } }