mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
420 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|