import { ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { Passenger as T } from '@edr/types'; import { PrismaService } from '../../common/prisma.service'; import { SupportGateway } from './support.gateway'; type Side = 'USER' | 'AGENT'; type PrismaSender = 'USER' | 'BOT' | 'AGENT'; type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED'; /** 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, ) {} // ---- 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); } 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 ------------------------------------------------------------ async getMessages( conversationId: string, asCustomer?: CustomerOwner, ): Promise { const conversation = await this.requireConversation(conversationId); if (asCustomer) this.assertOwns(conversation, asCustomer); const rows = await this.prisma.supportMessage.findMany({ where: { conversationId }, orderBy: { createdAt: 'asc' }, }); return rows.map((m) => this.toMessageDto(m)); } async sendMessage( conversationId: string, sender: Side, text: string, asCustomer?: CustomerOwner, ): Promise { const conversation = await this.requireConversation(conversationId); if (sender === 'USER') { this.assertOwns(conversation, asCustomer ?? {}); } const updated = await this.appendMessage(conversation, sender, text); const last = updated.messages[updated.messages.length - 1]; return this.toMessageDto(last); } 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, ) { const { conversation: updated } = await this.appendMessageRaw( conversation, sender, text, ); return updated as ConversationRow & { messages: any[] }; } /** Persist a message, bump the conversation's denormalized fields, emit live. */ private async appendMessageRaw( conversation: ConversationRow, sender: PrismaSender, text: string, ): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> { const message = await this.prisma.supportMessage.create({ data: { conversationId: conversation.id, sender, text }, }); const updated = (await this.prisma.supportConversation.update({ where: { id: conversation.id }, data: { lastMessageAt: message.createdAt, lastMessagePreview: text.slice(0, 280), lastMessageSender: sender, }, include: { messages: { orderBy: { createdAt: 'asc' } } }, })) as ConversationRow & { messages: any[] }; const dto = this.toConversationDto(updated, 0); this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message)); return { conversation: updated, message }; } 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 toMessageDto(m: { id: string; conversationId: string; sender: PrismaSender; text: string; createdAt: Date; }): T.PassengerSupportMessageDto { return { id: m.id, conversationId: m.conversationId, sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT, text: m.text, createdAt: m.createdAt.toISOString(), }; } /** 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; } }