mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
feat: add the support feature to the passenger api
This commit is contained in:
@@ -1,35 +1,419 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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) {}
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private gateway: SupportGateway,
|
||||
) {}
|
||||
|
||||
getFaqCategories() { return this.prisma.faqCategory.findMany({ include: { _count: { select: { articles: true } } } }); }
|
||||
// ---- FAQ (unchanged) ---------------------------------------------------
|
||||
|
||||
getFaqArticles(categoryId: string) { return this.prisma.faqArticle.findMany({ where: { categoryId }, orderBy: { rank: 'asc' } }); }
|
||||
|
||||
startConversation(userId: string) { return this.prisma.supportConversation.create({ data: { userId } }); }
|
||||
|
||||
async sendMessage(conversationId: string, sender: 'USER' | 'BOT' | 'AGENT', text: string) {
|
||||
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId } });
|
||||
if (!conv) throw new NotFoundException('Conversation not found');
|
||||
const message = await this.prisma.supportMessage.create({ data: { conversationId, sender, text } });
|
||||
if (sender === 'USER') await this.prisma.supportMessage.create({ data: { conversationId, sender: 'BOT', text: this.getBotReply(text) } });
|
||||
return message;
|
||||
getFaqCategories() {
|
||||
return this.prisma.faqCategory.findMany({
|
||||
include: { _count: { select: { articles: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string) {
|
||||
const conv = await this.prisma.supportConversation.findUnique({ where: { id: conversationId }, include: { messages: { orderBy: { createdAt: 'asc' } } } });
|
||||
if (!conv) throw new NotFoundException('Conversation not found');
|
||||
return conv;
|
||||
getFaqArticles(categoryId: string) {
|
||||
return this.prisma.faqArticle.findMany({
|
||||
where: { categoryId },
|
||||
orderBy: { rank: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private getBotReply(text: string): string {
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('cancel') || lower.includes('refund')) return 'To cancel or refund, go to Bookings and select the booking. Refunds are processed within 3-5 business days.';
|
||||
if (lower.includes('miss') || lower.includes('missed')) return 'If you missed your train, please check the Disruptions section for alternative options.';
|
||||
if (lower.includes('seat')) return 'You can select or change seats during booking. Seat changes after confirmation may incur a fee.';
|
||||
return 'Thank you for contacting EDR support. An agent will assist you shortly.';
|
||||
// ---- 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user