import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { isSupportAttachmentAllowed, Passenger as T, SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE, } from '@edr/types'; import { randomUUID } from 'crypto'; import { Readable } from 'stream'; import { PrismaService } from '../../common/prisma.service'; import { MinioService } from '../storage/minio.service'; import { decodeMessageCursor, encodeMessageCursor } from './message-cursor'; import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto'; import { SupportGateway } from './support.gateway'; type Side = 'USER' | 'AGENT'; type PrismaSender = 'USER' | 'BOT' | 'AGENT'; type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED'; /** Stand-in preview for a message that is nothing but files. */ const ATTACHMENT_ONLY_PREVIEW = '📎'; interface MessagesQuery { before?: string; limit?: number; } type AttachmentRow = { id: string; name: string; mimeType: string; size: number; url: string; }; type MessageRow = { id: string; conversationId: string; sender: PrismaSender; text: string | null; createdAt: Date; attachments?: AttachmentRow[]; }; /** Who the caller is on the customer side: an authed passenger or a guest. */ export interface CustomerOwner { iamUserId?: string | null; guestId?: string | null; } interface ListQuery { status?: PrismaStatus; search?: string; page?: number; limit?: number; } type ConversationRow = { id: string; userId: string | null; guestId: string | null; guestName: string | null; guestEmail: string | null; guestPhone: string | null; passengerId: string | null; passengerName: string | null; subject: string | null; status: PrismaStatus; assignedAgentId: string | null; lastMessageAt: Date | null; lastMessagePreview: string | null; lastMessageSender: PrismaSender | null; userLastReadAt: Date | null; agentLastReadAt: Date | null; createdAt: Date; updatedAt: Date; }; @Injectable() export class SupportService { constructor( private prisma: PrismaService, private gateway: SupportGateway, private minio: MinioService, ) {} // ---- FAQ (unchanged) --------------------------------------------------- getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } }, }); } getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' }, }); } // ---- customer: authed passenger --------------------------------------- async createConversation( iamUserId: string, input: { subject: string; initialMessage: string }, ): Promise { const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, include: { user: { select: { fullName: true } } }, }); const conversation = (await this.prisma.supportConversation.create({ data: { userId: iamUserId, passengerId: passenger?.id ?? null, passengerName: passenger?.user?.fullName ?? null, subject: input.subject, status: 'OPEN', }, })) as ConversationRow; return this.firstMessage(conversation, input.initialMessage); } // ---- customer: guest (unauthenticated) -------------------------------- async createGuestConversation(input: { guestId: string; name: string; email: string; phone?: string; subject: string; initialMessage: string; }): Promise { const conversation = (await this.prisma.supportConversation.create({ data: { guestId: input.guestId, guestName: input.name, guestEmail: input.email, guestPhone: input.phone ?? null, passengerName: input.name, // uniform display name for the agent inbox subject: input.subject, status: 'OPEN', }, })) as ConversationRow; return this.firstMessage(conversation, input.initialMessage); } // ---- customer: device-scoped single thread (portal) ------------------- /** * The device's single conversation + its NEWEST page of messages * ({conversation:null} if none). Not the whole thread — the client pages back * with `nextCursor` exactly as the backoffice does. */ async getDeviceThread( deviceId: string, query: MessagesQuery = {}, ): Promise { const empty = { conversation: null, messages: [], nextCursor: null }; if (!deviceId) return empty; const c = (await this.prisma.supportConversation.findFirst({ where: { guestId: deviceId }, orderBy: { createdAt: 'asc' }, })) as ConversationRow | null; if (!c) return empty; const page = await this.listMessages(c.id, query); const unread = await this.computeUnread([c], 'USER'); return { conversation: this.toConversationDto(c, unread.get(c.id) ?? 0), messages: page.items, nextCursor: page.nextCursor, }; } /** Append a message to the device's thread, creating it on first message. */ async sendDeviceMessage( deviceId: string, text: string | undefined, attachments: Express.Multer.File[] = [], ): Promise { let c = (await this.prisma.supportConversation.findFirst({ where: { guestId: deviceId }, orderBy: { createdAt: 'asc' }, })) as ConversationRow | null; if (!c) { c = (await this.prisma.supportConversation.create({ data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' }, })) as ConversationRow; } return this.appendMessage(c, 'USER', text, attachments); } /** Mark the device's thread read (customer side). */ async markDeviceRead(deviceId: string): Promise<{ unreadCount: number }> { const c = await this.prisma.supportConversation.findFirst({ where: { guestId: deviceId }, orderBy: { createdAt: 'asc' }, }); if (c) { await this.prisma.supportConversation.update({ where: { id: c.id }, data: { userLastReadAt: new Date() }, }); } return this.unreadCount('USER', { guestId: deviceId }); } async listForCustomer( owner: CustomerOwner, query: ListQuery, ): Promise { const scope = this.ownerScope(owner); const where = { ...this.listWhere(query), ...scope }; const rows = (await this.prisma.supportConversation.findMany({ where, orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], take: query.limit ?? 100, skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), })) as ConversationRow[]; const count = await this.prisma.supportConversation.count({ where }); return this.buildListResult(rows, count, 'USER'); } // ---- agent (backoffice) ------------------------------------------------ async listForAgents(query: ListQuery): Promise { const where = this.listWhere(query); const rows = (await this.prisma.supportConversation.findMany({ where, orderBy: [{ lastMessageAt: 'desc' }, { createdAt: 'desc' }], take: query.limit ?? 100, skip: ((query.page ?? 1) - 1) * (query.limit ?? 100), })) as ConversationRow[]; const count = await this.prisma.supportConversation.count({ where }); return this.buildListResult(rows, count, 'AGENT'); } async setStatus( conversationId: string, status: PrismaStatus, ): Promise { await this.requireConversation(conversationId); const updated = (await this.prisma.supportConversation.update({ where: { id: conversationId }, data: { status }, })) as ConversationRow; const dto = this.toConversationDto(updated, 0); this.gateway.emitConversationUpdated(this.ownerRoom(updated), dto); return dto; } // ---- shared ------------------------------------------------------------ /** One page of a thread, newest first. See {@link listMessages}. */ async getMessages( conversationId: string, query: MessagesQuery = {}, asCustomer?: CustomerOwner, ): Promise { const conversation = await this.requireConversation(conversationId); if (asCustomer) this.assertOwns(conversation, asCustomer); return this.listMessages(conversationId, query); } async sendMessage( conversationId: string, sender: Side, text: string | undefined, asCustomer?: CustomerOwner, attachments: Express.Multer.File[] = [], ): Promise { const conversation = await this.requireConversation(conversationId); if (sender === 'USER') { this.assertOwns(conversation, asCustomer ?? {}); } return this.appendMessage(conversation, sender, text, attachments); } async markRead( conversationId: string, side: Side, asCustomer?: CustomerOwner, ): Promise<{ unreadCount: number }> { const conversation = await this.requireConversation(conversationId); if (side === 'USER') { this.assertOwns(conversation, asCustomer ?? {}); await this.prisma.supportConversation.update({ where: { id: conversationId }, data: { userLastReadAt: new Date() }, }); return this.unreadCount('USER', asCustomer); } await this.prisma.supportConversation.update({ where: { id: conversationId }, data: { agentLastReadAt: new Date() }, }); return this.unreadCount('AGENT'); } async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> { const rows = (await this.prisma.supportConversation.findMany({ where: side === 'USER' ? this.ownerScope(owner ?? {}) : {}, select: { id: true, userLastReadAt: true, agentLastReadAt: true }, })) as Array<{ id: string; userLastReadAt: Date | null; agentLastReadAt: Date | null; }>; const map = await this.computeUnread(rows, side); let unreadCount = 0; for (const n of map.values()) if (n > 0) unreadCount++; return { unreadCount }; } // ---- internals --------------------------------------------------------- private async firstMessage( conversation: ConversationRow, text: string, ): Promise { const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text); return this.toConversationDto(updated, 0); } private async appendMessage( conversation: ConversationRow, sender: PrismaSender, text: string | undefined, attachments: Express.Multer.File[] = [], ): Promise { const { message } = await this.appendMessageRaw(conversation, sender, text, attachments); return message; } /** * One page of a thread, walking backwards from newest. * * Keyset, not offset: a message arriving while the reader is scrolled back * would shift every offset by one and duplicate/skip rows across pages. Rides * the (conversationId, createdAt) index; the id is a tiebreak for messages * sharing a millisecond. */ private async listMessages( conversationId: string, query: MessagesQuery, ): Promise { const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT; const before = query.before ? decodeMessageCursor(query.before) : undefined; const rows = (await this.prisma.supportMessage.findMany({ where: { conversationId, ...(before ? { // Strictly older than the cursor in (createdAt, id) order. OR: [ { createdAt: { lt: before.createdAt } }, { createdAt: before.createdAt, id: { lt: before.id }, }, ], } : {}), }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], // One more than asked, to tell "there's another page" from "this page was // simply full" without a second COUNT. take: limit + 1, include: { attachments: { orderBy: { createdAt: 'asc' } } }, })) as MessageRow[]; const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; const oldest = page[page.length - 1]; const nextCursor = hasMore && oldest ? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id }) : null; // Flip to oldest-first so a page prepends as one block. const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m))); return { items, nextCursor }; } /** Persist a message (+ attachments), bump denormalized fields, emit live. */ private async appendMessageRaw( conversation: ConversationRow, sender: PrismaSender, text: string | undefined, attachments: Express.Multer.File[] = [], ): Promise<{ conversation: ConversationRow; message: T.PassengerSupportMessageDto; }> { const trimmed = (text ?? '').trim(); this.assertSendable(trimmed, attachments); const message = await this.prisma.supportMessage.create({ // NULL, not "", so "no text" is representable rather than inferred. The // DTO flattens it back to "" for rendering. data: { conversationId: conversation.id, sender, text: trimmed || null }, }); // The row has to exist before the files, since each is stored against // `messageId`. That leaves a window: if an 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: AttachmentRow[]; try { stored = await this.storeAttachments(message.id, attachments); } catch (error) { await this.prisma.supportMessage.delete({ where: { id: message.id } }); throw error; } const updated = (await this.prisma.supportConversation.update({ where: { id: conversation.id }, data: { lastMessageAt: message.createdAt, lastMessagePreview: this.buildPreview(trimmed, stored), lastMessageSender: sender, }, // Deliberately NOT `include: { messages: ... }` — that loaded every // message in the thread on every send just to read back the one we had in // hand. })) as ConversationRow; const messageDto = await this.toMessageDto({ ...(message as MessageRow), attachments: stored, }); const dto = this.toConversationDto(updated, 0); this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto); return { conversation: updated, message: messageDto }; } /** * Push bytes to MinIO, then record them. Object keys are namespaced by message * id and the stored name is sanitized so the key survives the round-trip * through its own URL (spaces/unicode would otherwise percent-encode and no * longer match the key). */ private async storeAttachments( messageId: string, files: Express.Multer.File[], ): Promise { return Promise.all( files.map(async (file) => { const safeName = file.originalname .normalize('NFKD') .replace(/[^\w.\-]+/g, '_') .replace(/_{2,}/g, '_') .replace(/^_+|_+$/g, ''); // The random segment is load-bearing: `Date.now()` is NOT unique across // this batch, since every callback runs to its first await in the same // tick and reads the same millisecond. Two files sharing a name — e.g. // two pasted screenshots, which browsers both call "image.png" — would // otherwise build the same key and silently overwrite each other. const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`; const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype); return this.prisma.supportAttachment.create({ data: { messageId, name: file.originalname, mimeType: file.mimetype, size: file.size, url, }, }); }), ); } /** * Chat upload rules — kept in step with the freight side via the shared * SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup * and this is a file one user pushes at another. */ 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 filenames when there's no text. */ private buildPreview(text: string, attachments: AttachmentRow[]): 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: ConversationRow[], count: number, side: Side, ): Promise { const unreadMap = await this.computeUnread(rows, side); const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0)); let unreadCount = 0; for (const n of unreadMap.values()) if (n > 0) unreadCount++; return { items, count, unreadCount }; } private async computeUnread( rows: Array<{ id: string; userLastReadAt: Date | null; agentLastReadAt: Date | null; }>, side: Side, ): Promise> { const map = new Map(); if (rows.length === 0) return map; const otherSender: PrismaSender = side === 'USER' ? 'AGENT' : 'USER'; const ids = rows.map((r) => r.id); const msgs = await this.prisma.supportMessage.findMany({ where: { conversationId: { in: ids }, sender: otherSender }, select: { conversationId: true, createdAt: true }, }); const cursorById = new Map( rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]), ); for (const m of msgs) { const cursor = cursorById.get(m.conversationId) ?? null; if (!cursor || m.createdAt > cursor) { map.set(m.conversationId, (map.get(m.conversationId) ?? 0) + 1); } } return map; } private listWhere(query: ListQuery) { const where: Record = {}; if (query.status) where.status = query.status; if (query.search?.trim()) { const contains = query.search.trim(); where.OR = [ { subject: { contains, mode: 'insensitive' } }, { passengerName: { contains, mode: 'insensitive' } }, { guestEmail: { contains, mode: 'insensitive' } }, ]; } return where; } /** Prisma where-fragment scoping to the calling customer (authed or guest). */ private ownerScope(owner: CustomerOwner): Record { if (owner.iamUserId) return { userId: owner.iamUserId }; if (owner.guestId) return { guestId: owner.guestId }; // No identity ⇒ match nothing. return { id: '__none__' }; } private assertOwns(conversation: ConversationRow, owner: CustomerOwner): void { const ok = (owner.iamUserId && conversation.userId === owner.iamUserId) || (owner.guestId && conversation.guestId === owner.guestId); if (!ok) { throw new ForbiddenException('This conversation belongs to someone else.'); } } private ownerRoom(c: ConversationRow): string | null { if (c.guestId) return `guest:${c.guestId}`; if (c.userId) return `user:${c.userId}`; return null; } private async requireConversation(id: string): Promise { const conversation = (await this.prisma.supportConversation.findUnique({ where: { id }, })) as ConversationRow | null; if (!conversation) throw new NotFoundException('Conversation not found'); return conversation; } private toConversationDto( c: ConversationRow, unreadCount: number, ): T.PassengerSupportConversationDto { return { id: c.id, userId: c.userId, guestId: c.guestId, guestEmail: c.guestEmail, guestPhone: c.guestPhone, passengerId: c.passengerId, passengerName: c.passengerName ?? c.guestName ?? null, subject: c.subject, status: c.status as T.PassengerSupportStatus, assignedAgentId: c.assignedAgentId, lastMessageAt: c.lastMessageAt ? c.lastMessageAt.toISOString() : null, lastMessagePreview: c.lastMessagePreview, lastMessageSender: this.toDtoSender(c.lastMessageSender), unreadCount, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), }; } private async toMessageDto(m: MessageRow): Promise { return { id: m.id, conversationId: m.conversationId, sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT, text: m.text ?? '', attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)), createdAt: m.createdAt.toISOString(), }; } /** * Where the client fetches the bytes: this API's own stream route, NOT a * presigned MinIO URL. Presigned object URLs are not reachable from the * browser in this deployment, which is why every working file in the platform * streams through the API instead. * * The path is audience-independent on purpose. A new message is pushed over * the socket to the device room *and* the backoffice room in one payload, so a * URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong * for one of the two recipients. * * The client can't use this path as an `` either — the agent side's * guard only reads a bearer header, which an image request can't send — so the * web apps fetch it through their authenticated client and render a blob. */ private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto { return { id: a.id, name: a.name, mimeType: a.mimeType, size: a.size, url: `/support/attachments/${a.id}`, }; } /** * Bytes for a chat attachment. * * Deliberately unscoped, and this is a trade-off worth naming: passenger * support threads are already reachable by whoever holds the device/guest id * (see the device routes — "anyone with the device id can see that thread", * the accepted MVP posture), and the agent routes admit any authenticated * caller pending real staff gating. Scoping this endpoint tighter than the * thread it belongs to would buy nothing, so it matches that posture: the * attachment UUID is the capability. * * TODO: tighten alongside the agent-route staff permission — at that point * both the thread and its attachments should be gated the same way. */ async streamAttachment( fileId: string, ): Promise<{ stream: Readable; mimeType: string; name: string }> { const attachment = await this.prisma.supportAttachment.findUnique({ where: { id: fileId }, }); if (!attachment) throw new NotFoundException('Attachment not found'); const objectName = this.minio.getObjectNameFromUrl(attachment.url); return { stream: await this.minio.getFileStream(objectName), mimeType: attachment.mimeType, name: attachment.name, }; } /** Legacy BOT messages are surfaced as AGENT to the UI. */ private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null { if (!s) return null; return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT; } }