mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 22:30:55 +00:00
701 lines
24 KiB
TypeScript
701 lines
24 KiB
TypeScript
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<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);
|
|
}
|
|
|
|
// ---- 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<T.PassengerSupportThreadDto> {
|
|
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<T.PassengerSupportMessageDto> {
|
|
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<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 ------------------------------------------------------------
|
|
|
|
/** One page of a thread, newest first. See {@link listMessages}. */
|
|
async getMessages(
|
|
conversationId: string,
|
|
query: MessagesQuery = {},
|
|
asCustomer?: CustomerOwner,
|
|
): Promise<T.PassengerSupportMessageListResult> {
|
|
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<T.PassengerSupportMessageDto> {
|
|
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<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 | undefined,
|
|
attachments: Express.Multer.File[] = [],
|
|
): Promise<T.PassengerSupportMessageDto> {
|
|
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<T.PassengerSupportMessageListResult> {
|
|
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<AttachmentRow[]> {
|
|
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<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 async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
|
|
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 `<img src>` 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;
|
|
}
|
|
}
|