Files
edr-platform/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts
2026-07-17 10:59:25 +00:00

368 lines
12 KiB
TypeScript

import {
SendSupportMessageResult,
SupportAuthorRole,
SupportConversationDto,
SupportConversationListResult,
SupportMessageDto,
} from "@edr/types";
import {
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { QueryFailedError } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
import { SupportConversation } from "./entities/support-conversation.entity";
import { SupportMessage } from "./entities/support-message.entity";
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";
@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,
) {}
// ---- 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): Promise<SupportMessageDto[]> {
const ctx = await this.resolveCustomer(userId);
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
if (!conversation) return [];
return this.listMessages(conversation.id);
}
/** Send as the customer, opening the thread if this is the first message. */
async sendAsCustomer(
userId: string,
body: string,
): 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,
);
return {
conversation: this.toConversationDto(updated, 0),
message: this.toMessageDto(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,
): Promise<SupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
const { message } = await this.appendMessage(
conversation,
userId,
SupportAuthorRole.AGENT,
body,
);
return this.toMessageDto(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 ------------------------------------------------------------
/**
* A thread's messages. Pass `asCustomerUserId` to enforce that the caller's
* company owns it (portal route); omit for agents, who see every thread.
*/
async getMessages(
conversationId: string,
asCustomerUserId?: string,
): Promise<SupportMessageDto[]> {
const conversation = await this.requireConversation(conversationId);
if (asCustomerUserId) {
await this.assertCustomerOwns(conversation, asCustomerUserId);
}
return this.listMessages(conversationId);
}
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,
): Promise<SupportMessageDto[]> {
const rows = await this.messages.listByConversation(conversationId);
return rows.map((m) => this.toMessageDto(m));
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
private async appendMessage(
conversation: SupportConversation,
userId: string,
role: SupportAuthorRole,
body: string,
authorName?: string | null,
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
const message = await this.messages.create({
conversationId: conversation.id,
authorUserId: userId,
authorRole: role,
authorName: authorName ?? null,
body,
});
conversation.lastMessageAt = message.createdAt;
conversation.lastMessagePreview = body.slice(0, 280);
conversation.lastMessageAuthorRole = role;
await this.conversations.update(conversation.id, {
lastMessageAt: conversation.lastMessageAt,
lastMessagePreview: conversation.lastMessagePreview,
lastMessageAuthorRole: role,
});
const dto = this.toConversationDto(conversation, 0);
this.gateway.emitMessage(
conversation.companyId,
dto,
this.toMessageDto(message),
);
return { conversation, message };
}
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(),
};
}
private toMessageDto(m: SupportMessage): SupportMessageDto {
return {
id: m.id,
conversationId: m.conversationId,
authorUserId: m.authorUserId,
authorRole: m.authorRole,
authorName: m.authorName ?? null,
body: m.body,
createdAt: new Date(m.createdAt).toISOString(),
};
}
}