mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
fix: added chat to the freight api
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user