Files
edr-platform/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts
2026-07-18 08:52:06 +00:00

584 lines
19 KiB
TypeScript

import {
isSupportAttachmentAllowed,
SendSupportMessageResult,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
SUPPORT_ATTACHMENT_RESOURCE,
SupportAttachmentDto,
SupportAuthorRole,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
SupportMessageListResult,
} from "@edr/types";
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { QueryFailedError } from "typeorm";
import { BackofficeService } from "../backoffice/backoffice.service";
import { CompaniesService } from "../companies/companies.service";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
import { FileRecord } from "../files/entities/file.entity";
import { FilesService } from "../files/files.service";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import {
ListMessagesQueryDto,
SUPPORT_MESSAGES_DEFAULT_LIMIT,
} from "./dto/list-messages-query.dto";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
import { SupportChatGateway } from "./support-chat.gateway";
import { SupportConversationRepository } from "./support-conversation.repository";
import { SupportMessageRepository } from "./support-message.repository";
interface CustomerContext {
companyId: string;
companyName?: string | null;
authorName?: string | null;
}
/** Postgres unique_violation — the one-thread-per-company index fired. */
const PG_UNIQUE_VIOLATION = "23505";
/** Stand-in preview for a message that is nothing but files. */
const ATTACHMENT_ONLY_PREVIEW = "📎";
@Injectable()
export class SupportChatService {
constructor(
private readonly conversations: SupportConversationRepository,
private readonly messages: SupportMessageRepository,
private readonly gateway: SupportChatGateway,
private readonly externalProfiles: ExternalProfileRepository,
private readonly companies: CompaniesService,
private readonly files: FilesService,
private readonly backoffice: BackofficeService,
) {}
// ---- customer (portal) -------------------------------------------------
/**
* The caller's company thread, or null if nobody has spoken yet. Deliberately
* does *not* create: opening the widget shouldn't push an empty thread into
* the agent inbox. Creation happens on the first message.
*/
async getCustomerConversation(
userId: string,
): Promise<SupportConversationDto | null> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return null;
const unread = await this.messages.unreadCountsByConversation(
[conversation.id],
SupportAuthorRole.CUSTOMER,
);
return this.toConversationDto(
conversation,
unread.get(conversation.id) ?? 0,
);
}
async getCustomerMessages(
userId: string,
query: ListMessagesQueryDto = {},
): Promise<SupportMessageListResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (!conversation) return { items: [], nextCursor: null };
return this.listMessages(conversation.id, query);
}
/** Send as the customer, opening the thread if this is the first message. */
async sendAsCustomer(
userId: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SendSupportMessageResult> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.getOrCreate(
ctx.companyId,
ctx.companyName,
userId,
);
const { conversation: updated, message } = await this.appendMessage(
conversation,
userId,
SupportAuthorRole.CUSTOMER,
body,
ctx.authorName,
attachments,
);
return {
conversation: this.toConversationDto(updated, 0),
message: message,
};
}
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(
ctx.companyId,
);
if (conversation) {
await this.conversations.update(conversation.id, {
customerLastReadAt: new Date(),
});
}
return this.unreadCount(SupportAuthorRole.CUSTOMER, userId);
}
// ---- agent (backoffice) ------------------------------------------------
async listForAgents(
query: ListConversationsQueryDto,
): Promise<SupportConversationListResult> {
const [rows, count] = await this.conversations.listAll(
SupportAuthorRole.AGENT,
query,
);
return this.buildListResult(rows, count, SupportAuthorRole.AGENT);
}
/**
* Open (or reuse) the thread with a company so an agent can start chatting.
* Idempotent — clicking a company that already has a thread just returns it.
*/
async startWithCompany(companyId: string): Promise<SupportConversationDto> {
const company = await this.companies.findCompanyById(companyId);
const existing = await this.conversations.findByCompanyId(companyId);
const conversation =
existing ?? (await this.getOrCreate(companyId, company.name, null));
const unread = await this.messages.unreadCountsByConversation(
[conversation.id],
SupportAuthorRole.AGENT,
);
const dto = this.toConversationDto(
conversation,
unread.get(conversation.id) ?? 0,
);
if (!existing) {
// Surface the new thread in every agent's inbox right away.
this.gateway.emitConversationUpdated(conversation.companyId, dto);
}
return dto;
}
async sendAsAgent(
conversationId: string,
userId: string,
body: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<SupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
const { message } = await this.appendMessage(
conversation,
userId,
SupportAuthorRole.AGENT,
body,
undefined,
attachments,
);
return message;
}
async markAgentRead(
conversationId: string,
userId: string,
): Promise<{ unreadCount: number }> {
await this.requireConversation(conversationId);
await this.conversations.update(conversationId, {
agentLastReadAt: new Date(),
});
return this.unreadCount(SupportAuthorRole.AGENT, userId);
}
// ---- shared ------------------------------------------------------------
/**
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
* to enforce that the caller's company owns it (portal route); omit for
* agents, who see every thread.
*/
async getMessages(
conversationId: string,
query: ListMessagesQueryDto = {},
asCustomerUserId?: string,
): Promise<SupportMessageListResult> {
const conversation = await this.requireConversation(conversationId);
if (asCustomerUserId) {
await this.assertCustomerOwns(conversation, asCustomerUserId);
}
return this.listMessages(conversationId, query);
}
/**
* May `userId` read the message that a chat attachment hangs off? Backstop for
* the file-download route, which otherwise streams any file to any
* authenticated caller who knows its UUID.
*
* Backoffice staff see every thread (they work a shared inbox); a portal user
* sees only their own company's. Fails **closed** — an unresolvable message,
* conversation, or staff list denies rather than falls through, since the
* caller uses this to decide whether to hand over raw bytes.
*/
async canUserAccessMessage(
messageId: string,
userId: string,
): Promise<boolean> {
const message = await this.messages.findById(messageId);
if (!message) return false;
const conversation = await this.conversations.findById(
message.conversationId,
);
if (!conversation) return false;
try {
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
if (staffIds.includes(userId)) return true;
} catch {
// Staff lookup is best-effort for room-joining in the gateway, but here it
// gates bytes: on failure fall through to the (stricter) company check
// rather than assuming staff.
}
const profile = await this.externalProfiles.findByUserId(userId);
return Boolean(
profile?.companyId && profile.companyId === conversation.companyId,
);
}
async unreadCount(
side: SupportAuthorRole,
userId: string,
): Promise<{ unreadCount: number }> {
if (side === SupportAuthorRole.CUSTOMER) {
const ctx = await this.resolveCustomer(userId);
return {
unreadCount: await this.messages.countUnreadConversations(
side,
ctx.companyId,
),
};
}
return { unreadCount: await this.messages.countUnreadConversations(side) };
}
// ---- internals ---------------------------------------------------------
/**
* Fetch the company's thread or open it. Two first-messages can race here, so
* we let the unique index arbitrate and re-read the winner rather than
* locking — the loser's insert is the only wasted work.
*/
private async getOrCreate(
companyId: string,
companyName: string | null | undefined,
createdByUserId: string | null,
): Promise<SupportConversation> {
const existing = await this.conversations.findByCompanyId(companyId);
if (existing) return existing;
try {
return await this.conversations.create({
companyId,
companyName: companyName ?? null,
createdByUserId,
});
} catch (error) {
if (
error instanceof QueryFailedError &&
(error as QueryFailedError & { code?: string }).code ===
PG_UNIQUE_VIOLATION
) {
const winner = await this.conversations.findByCompanyId(companyId);
if (winner) return winner;
}
throw error;
}
}
private async listMessages(
conversationId: string,
query: ListMessagesQueryDto,
): Promise<SupportMessageListResult> {
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
const before = query.before ? decodeMessageCursor(query.before) : undefined;
// The repo returns newest-first and over-fetches by one to probe for a
// further page.
const rows = await this.messages.listByConversation(
conversationId,
limit,
before,
);
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const oldest = page[page.length - 1];
const nextCursor =
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
// Flip to oldest-first so the client can prepend a page as one block.
const items = await this.toMessageDtos([...page].reverse());
return { items, nextCursor };
}
/**
* Persist a message (plus any attachments), bump the conversation's
* denormalized fields, emit live.
*
* Files are validated *before* the row is written: a rejected upload should
* leave no message behind, and a half-uploaded batch is worse than none.
*/
private async appendMessage(
conversation: SupportConversation,
userId: string,
role: SupportAuthorRole,
body: string | undefined,
authorName?: string | null,
attachments: Express.Multer.File[] = [],
): Promise<{
conversation: SupportConversation;
message: SupportMessageDto;
}> {
const text = (body ?? "").trim();
this.assertSendable(text, attachments);
const message = await this.messages.create({
conversationId: conversation.id,
authorUserId: userId,
authorRole: role,
authorName: authorName ?? null,
// NULL, not "", so "this message has no text" is representable rather than
// inferred. The DTO flattens it back to "" for rendering.
body: text || null,
});
// The row has to exist before the files, since each one is stored against
// `resourceId = message.id`. That leaves a window: if a 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: FileRecord[];
try {
stored = await Promise.all(
attachments.map((file) =>
this.files.upload({
resourceId: message.id,
resource: SUPPORT_ATTACHMENT_RESOURCE,
code: "attachment",
file,
}),
),
);
} catch (error) {
await this.messages.softDelete(message.id);
throw error;
}
conversation.lastMessageAt = message.createdAt;
conversation.lastMessagePreview = this.buildPreview(text, stored);
conversation.lastMessageAuthorRole = role;
await this.conversations.update(conversation.id, {
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
lastMessageAuthorRole: role,
});
const messageDto = await this.toMessageDto(message, stored);
const dto = this.toConversationDto(conversation, 0);
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
return { conversation, message: messageDto };
}
/**
* Guard the chat-specific upload rules. These are tighter than
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
* which exist for scanned business documents — chat files are pushed at
* another human, so the allowlist is narrower and SVG is excluded outright.
*/
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 the filenames when there's no text. */
private buildPreview(text: string, attachments: FileRecord[]): 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: SupportConversation[],
count: number,
side: SupportAuthorRole,
companyId?: string,
): Promise<SupportConversationListResult> {
const unreadMap = await this.messages.unreadCountsByConversation(
rows.map((r) => r.id),
side,
);
const items = rows.map((r) =>
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
);
const unreadCount = await this.messages.countUnreadConversations(
side,
companyId,
);
return { items, count, unreadCount };
}
private async resolveCustomer(userId: string): Promise<CustomerContext> {
const profile = await this.externalProfiles.findByUserId(userId);
if (!profile?.companyId) {
throw new ForbiddenException(
"No company profile is linked to this account.",
);
}
const name = [profile.firstName, profile.lastName]
.filter(Boolean)
.join(" ")
.trim();
return {
companyId: profile.companyId,
companyName: profile.company?.name ?? null,
authorName: name || null,
};
}
private async assertCustomerOwns(
conversation: SupportConversation,
userId: string,
): Promise<CustomerContext> {
const ctx = await this.resolveCustomer(userId);
if (conversation.companyId !== ctx.companyId) {
throw new ForbiddenException(
"This conversation belongs to another company.",
);
}
return ctx;
}
private async requireConversation(id: string): Promise<SupportConversation> {
const conversation = await this.conversations.findById(id);
if (!conversation) {
throw new NotFoundException("Conversation not found.");
}
return conversation;
}
private toConversationDto(
c: SupportConversation,
unreadCount: number,
): SupportConversationDto {
return {
id: c.id,
companyId: c.companyId,
companyName: c.companyName ?? null,
createdByUserId: c.createdByUserId ?? null,
lastMessageAt: c.lastMessageAt
? new Date(c.lastMessageAt).toISOString()
: null,
lastMessagePreview: c.lastMessagePreview ?? null,
lastMessageAuthorRole: c.lastMessageAuthorRole ?? null,
unreadCount,
createdAt: new Date(c.createdAt).toISOString(),
updatedAt: new Date(c.updatedAt).toISOString(),
};
}
/** Hydrate + map a page of messages, batching the attachment lookup. */
private async toMessageDtos(
rows: SupportMessage[],
): Promise<SupportMessageDto[]> {
if (rows.length === 0) return [];
const grouped = await this.files.findByResourceIdsGrouped(
rows.map((r) => r.id),
SUPPORT_ATTACHMENT_RESOURCE,
);
return Promise.all(
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
);
}
private async toMessageDto(
m: SupportMessage,
attachments: FileRecord[],
): Promise<SupportMessageDto> {
return {
id: m.id,
conversationId: m.conversationId,
authorUserId: m.authorUserId,
authorRole: m.authorRole,
authorName: m.authorName ?? null,
body: m.body ?? "",
attachments: attachments.map((a) => this.toAttachmentDto(a)),
createdAt: new Date(m.createdAt).toISOString(),
};
}
/**
* Where the browser fetches the bytes: the API's own ownership-checked stream
* route, NOT a presigned MinIO URL.
*
* Presigned object URLs are not reachable from the browser in this deployment
* — the same reason every other file in the app streams through
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
* attachments stream through `GET /api/support/attachments/:id`, which runs the
* same-company / staff ownership check before serving a byte.
*
* A root-relative path; the web app prepends its API origin. The `<img>` sends
* the `auth-token` cookie automatically (same-site across dev ports), which is
* how the guard authenticates a request that can't carry a bearer header.
*/
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
return {
id: f.id,
name: f.name,
mimeType: f.mimeType,
size: f.size,
url: `/api/support/attachments/${f.id}`,
};
}
}