Files
edr-platform/apps/edr-passenger-api/src/modules/support/support.service.ts

420 lines
13 KiB
TypeScript

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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationListResult> {
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<T.PassengerSupportConversationListResult> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportMessageDto[]> {
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<T.PassengerSupportMessageDto> {
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<T.PassengerSupportConversationDto> {
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<T.PassengerSupportConversationListResult> {
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<Map<string, number>> {
const map = new Map<string, number>();
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<string, unknown> = {};
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<string, unknown> {
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<ConversationRow> {
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;
}
}