mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: added chat to the freight api
This commit is contained in:
@@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module";
|
||||
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
@@ -159,6 +160,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SupportChatModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Customer-support chat. A `support_conversations` row is the single ongoing
|
||||
* thread with a company; `support_messages` are its text messages. There is no
|
||||
* lifecycle column — a thread is opened by whichever side speaks first and
|
||||
* stays open. Enum-like columns are varchar (no PG enum churn).
|
||||
*
|
||||
* The unique index on `company_id` is load-bearing, not just an optimization:
|
||||
* the get-or-create path depends on it to settle concurrent first-messages.
|
||||
* It is partial on `deleted_at IS NULL` so a soft-deleted thread doesn't block
|
||||
* a fresh one.
|
||||
*/
|
||||
export class CreateSupportChat2310000000000 implements MigrationInterface {
|
||||
name = "CreateSupportChat2310000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_conversations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_id uuid NOT NULL,
|
||||
company_name varchar(200),
|
||||
created_by_user_id uuid,
|
||||
last_message_at timestamptz,
|
||||
last_message_preview varchar(280),
|
||||
last_message_author_role varchar(12),
|
||||
customer_last_read_at timestamptz,
|
||||
agent_last_read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_COMPANY"
|
||||
ON freight.support_conversations (company_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_CONV_LASTMSG"
|
||||
ON freight.support_conversations (last_message_at)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.support_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id uuid NOT NULL,
|
||||
author_user_id uuid NOT NULL,
|
||||
author_role varchar(12) NOT NULL,
|
||||
author_name varchar(200),
|
||||
body text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_SUPPORT_MSG_CONV_CREATED"
|
||||
ON freight.support_messages (conversation_id, created_at)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_MSG_CONV_CREATED"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.support_messages`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_LASTMSG"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_SUPPORT_CONV_COMPANY"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.support_conversations`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { WsAuthService } from "./ws-auth.service";
|
||||
WsAuthService,
|
||||
NotificationInboxService,
|
||||
],
|
||||
exports: [NotificationInboxService],
|
||||
// WsAuthService is reused by the support-chat gateway for handshake auth.
|
||||
exports: [NotificationInboxService, WsAuthService],
|
||||
})
|
||||
export class NotificationInboxModule {}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
export class ListConversationsQueryDto {
|
||||
@ApiPropertyOptional({ description: "Search company name." })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Keep only threads with unread messages.",
|
||||
default: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === true || value === "true")
|
||||
@IsBoolean()
|
||||
unreadOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class SendMessageDto implements ISendSupportMessageDto {
|
||||
@ApiProperty({ description: "Message text." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
body!: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StartSupportConversationDto as IStartSupportConversationDto } from "@edr/types";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsUUID } from "class-validator";
|
||||
|
||||
/** Agent opens the thread with a company before sending the first message. */
|
||||
export class StartConversationDto implements IStartSupportConversationDto {
|
||||
@ApiProperty({ description: "Customer company to chat with." })
|
||||
@IsUUID()
|
||||
companyId!: string;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* The single support thread for a customer **company**. Any portal user of that
|
||||
* company sees and continues it; backoffice agents work a shared inbox. Either
|
||||
* side may open it — whoever sends the first message — and it has no lifecycle:
|
||||
* no status, no resolve, no close.
|
||||
*
|
||||
* The unique index on `company_id` is what enforces one-thread-per-company; the
|
||||
* get-or-create path relies on it to settle races. Last-message fields are
|
||||
* denormalized so the inbox list can sort and preview without joining
|
||||
* `support_messages`. Read cursors are per-side (shared across a company's
|
||||
* users) — unread = messages from the other role newer than the side's cursor.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "support_conversations" })
|
||||
@Index("IDX_SUPPORT_CONV_COMPANY", ["companyId"], {
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL",
|
||||
})
|
||||
@Index("IDX_SUPPORT_CONV_LASTMSG", ["lastMessageAt"])
|
||||
export class SupportConversation extends BaseEntity {
|
||||
@Column({ name: "company_id", type: "uuid" })
|
||||
companyId!: string;
|
||||
|
||||
/** Denormalized company name for the agent inbox (resolved at creation). */
|
||||
@Column({ name: "company_name", type: "varchar", length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
/** Null when an agent opened the thread — no customer created it. */
|
||||
@Column({ name: "created_by_user_id", type: "uuid", nullable: true })
|
||||
createdByUserId?: string | null;
|
||||
|
||||
@Column({ name: "last_message_at", type: "timestamptz", nullable: true })
|
||||
lastMessageAt?: Date | null;
|
||||
|
||||
@Column({ name: "last_message_preview", type: "varchar", length: 280, nullable: true })
|
||||
lastMessagePreview?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "last_message_author_role",
|
||||
type: "varchar",
|
||||
length: 12,
|
||||
nullable: true,
|
||||
})
|
||||
lastMessageAuthorRole?: SupportAuthorRole | null;
|
||||
|
||||
@Column({ name: "customer_last_read_at", type: "timestamptz", nullable: true })
|
||||
customerLastReadAt?: Date | null;
|
||||
|
||||
@Column({ name: "agent_last_read_at", type: "timestamptz", nullable: true })
|
||||
agentLastReadAt?: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/** A single text message inside a {@link SupportConversation}. */
|
||||
@Entity({ schema: "freight", name: "support_messages" })
|
||||
@Index("IDX_SUPPORT_MSG_CONV_CREATED", ["conversationId", "createdAt"])
|
||||
export class SupportMessage extends BaseEntity {
|
||||
@Column({ name: "conversation_id", type: "uuid" })
|
||||
conversationId!: string;
|
||||
|
||||
@Column({ name: "author_user_id", type: "uuid" })
|
||||
authorUserId!: string;
|
||||
|
||||
@Column({ name: "author_role", type: "varchar", length: 12 })
|
||||
authorRole!: SupportAuthorRole;
|
||||
|
||||
/** Display name captured at send time (best-effort). */
|
||||
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||
authorName?: string | null;
|
||||
|
||||
@Column({ name: "body", type: "text" })
|
||||
body!: string;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
/** Backoffice (agent) support-chat endpoints. Shared inbox over all companies. */
|
||||
@ApiTags("support-chat-agent")
|
||||
@Controller("support/agent")
|
||||
export class SupportChatAgentController {
|
||||
constructor(private readonly service: SupportChatService) {}
|
||||
|
||||
@Get("conversations")
|
||||
@ApiOperation({ summary: "List all support threads (shared inbox)" })
|
||||
list(@Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForAgents(query);
|
||||
}
|
||||
|
||||
@Post("conversations")
|
||||
@ApiOperation({
|
||||
summary: "Start chatting with a company (returns the thread if one exists)",
|
||||
})
|
||||
start(@Body() body: StartConversationDto) {
|
||||
return this.service.startWithCompany(body.companyId);
|
||||
}
|
||||
|
||||
@Get("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "List messages in a thread" })
|
||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.getMessages(id);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "Reply as an agent" })
|
||||
send(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
) {
|
||||
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/read")
|
||||
@ApiOperation({ summary: "Mark a thread read (agent side)" })
|
||||
read(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.service.markAgentRead(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("unread-count")
|
||||
@ApiOperation({ summary: "Count unread threads (agent side)" })
|
||||
unread(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.unreadCount(
|
||||
SupportAuthorRole.AGENT,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
/**
|
||||
* Portal (customer) support-chat endpoints. The caller's company has exactly one
|
||||
* thread, so these are addressed as a singleton — no conversation id on the wire,
|
||||
* and nothing for a portal user to pick between.
|
||||
*/
|
||||
@ApiTags("support-chat")
|
||||
@Controller("support")
|
||||
export class SupportChatController {
|
||||
constructor(private readonly service: SupportChatService) {}
|
||||
|
||||
@Get("conversation")
|
||||
@ApiOperation({
|
||||
summary: "My company's support thread (null until someone speaks)",
|
||||
})
|
||||
conversation(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.getCustomerConversation(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("conversation/messages")
|
||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
||||
messages(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post("conversation/messages")
|
||||
@ApiOperation({
|
||||
summary: "Send a message as the customer, opening the thread if needed",
|
||||
})
|
||||
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
|
||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
||||
}
|
||||
|
||||
@Post("conversation/read")
|
||||
@ApiOperation({ summary: "Mark my company's thread read (customer side)" })
|
||||
read(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.markCustomerRead(resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get("unread-count")
|
||||
@ApiOperation({ summary: "Count my unread support messages" })
|
||||
unread(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.unreadCount(
|
||||
SupportAuthorRole.CUSTOMER,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
SUPPORT_CHAT_WS_EVENTS,
|
||||
SUPPORT_CHAT_WS_NAMESPACE,
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
} from "@edr/types";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from "@nestjs/websockets";
|
||||
import { Server, Socket } from "socket.io";
|
||||
|
||||
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||
import { WsAuthService } from "../notification-inbox/ws-auth.service";
|
||||
|
||||
/**
|
||||
* Server → client push for support chat. Clients only *listen* (no
|
||||
* `@SubscribeMessage`); the handshake is authenticated in `handleConnection`
|
||||
* (reusing the notification module's {@link WsAuthService}). Each socket joins a
|
||||
* room based on its side:
|
||||
* - backoffice staff → the shared `backoffice` room (see every conversation).
|
||||
* - portal users → their `company:<companyId>` room (their tickets only).
|
||||
*
|
||||
* A message is emitted to *both* the company room and the backoffice room so the
|
||||
* customer thread, the sender's echo, and every other agent's inbox update live.
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
namespace: SUPPORT_CHAT_WS_NAMESPACE,
|
||||
cors: { origin: true, credentials: true },
|
||||
})
|
||||
export class SupportChatGateway implements OnGatewayConnection {
|
||||
private readonly logger = new Logger(SupportChatGateway.name);
|
||||
|
||||
private static readonly BACKOFFICE_ROOM = "backoffice";
|
||||
|
||||
@WebSocketServer()
|
||||
private readonly server!: Server;
|
||||
|
||||
constructor(
|
||||
private readonly wsAuth: WsAuthService,
|
||||
private readonly backoffice: BackofficeService,
|
||||
private readonly externalProfiles: ExternalProfileRepository,
|
||||
) {}
|
||||
|
||||
async handleConnection(socket: Socket): Promise<void> {
|
||||
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
|
||||
if (!userId) {
|
||||
this.logger.debug(`Rejected support-chat handshake ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
socket.data.userId = userId;
|
||||
|
||||
try {
|
||||
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
|
||||
if (staffIds.includes(userId)) {
|
||||
await socket.join(SupportChatGateway.BACKOFFICE_ROOM);
|
||||
socket.data.side = "AGENT";
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Staff lookup failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const profile = await this.externalProfiles.findByUserId(userId);
|
||||
if (profile?.companyId) {
|
||||
await socket.join(this.companyRoom(profile.companyId));
|
||||
socket.data.side = "CUSTOMER";
|
||||
socket.data.companyId = profile.companyId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a new message + updated conversation to the company and backoffice rooms. */
|
||||
emitMessage(
|
||||
companyId: string,
|
||||
conversation: SupportConversationDto,
|
||||
message: SupportMessageDto,
|
||||
): void {
|
||||
const payload = { conversation, message };
|
||||
for (const room of this.targetRooms(companyId)) {
|
||||
const to = this.server.to(room);
|
||||
to.emit(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, payload);
|
||||
to.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a conversation metadata change (e.g. status) to both rooms. */
|
||||
emitConversationUpdated(
|
||||
companyId: string,
|
||||
conversation: SupportConversationDto,
|
||||
): void {
|
||||
for (const room of this.targetRooms(companyId)) {
|
||||
this.server
|
||||
.to(room)
|
||||
.emit(SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED, conversation);
|
||||
}
|
||||
}
|
||||
|
||||
private targetRooms(companyId: string): string[] {
|
||||
return [this.companyRoom(companyId), SupportChatGateway.BACKOFFICE_ROOM];
|
||||
}
|
||||
|
||||
private companyRoom(companyId: string): string {
|
||||
return `company:${companyId}`;
|
||||
}
|
||||
|
||||
private extractToken(socket: Socket): string | undefined {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) return authToken;
|
||||
|
||||
const queryToken = socket.handshake.query?.token;
|
||||
if (typeof queryToken === "string") return queryToken;
|
||||
|
||||
const header = socket.handshake.headers?.authorization;
|
||||
if (header?.startsWith("Bearer ")) return header.slice(7);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||
import { SupportChatController } from "./support-chat.controller";
|
||||
import { SupportChatGateway } from "./support-chat.gateway";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||
import { SupportMessageRepository } from "./support-message.repository";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SupportConversation, SupportMessage]),
|
||||
// ExternalProfileRepository — company lookup + ownership checks.
|
||||
// CompaniesService — resolve the company an agent opens a thread with.
|
||||
CompaniesModule,
|
||||
// BackofficeService.getAllCurrentEmployeeUserIds — staff room membership.
|
||||
BackofficeModule,
|
||||
// WsAuthService — reused handshake authentication for the gateway.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [SupportChatController, SupportChatAgentController],
|
||||
providers: [
|
||||
SupportConversationRepository,
|
||||
SupportMessageRepository,
|
||||
SupportChatGateway,
|
||||
SupportChatService,
|
||||
],
|
||||
})
|
||||
export class SupportChatModule {}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
|
||||
export interface ListConversationsOptions {
|
||||
search?: string;
|
||||
/** Keep only threads with at least one message the side hasn't read. */
|
||||
unreadOnly?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SupportConversationRepository extends BaseRepository<SupportConversation> {
|
||||
constructor(
|
||||
@InjectRepository(SupportConversation)
|
||||
repo: Repository<SupportConversation>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The company's thread, or null if neither side has spoken yet. */
|
||||
async findByCompanyId(companyId: string): Promise<SupportConversation | null> {
|
||||
return this.repository.findOne({ where: { companyId } });
|
||||
}
|
||||
|
||||
/** Every thread (backoffice shared inbox), most-recently-active first. */
|
||||
async listAll(
|
||||
side: SupportAuthorRole,
|
||||
opts: ListConversationsOptions = {},
|
||||
): Promise<[SupportConversation[], number]> {
|
||||
const page = opts.page && opts.page > 0 ? opts.page : 1;
|
||||
const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("c")
|
||||
.orderBy("c.last_message_at", "DESC", "NULLS LAST")
|
||||
.addOrderBy("c.created_at", "DESC")
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
if (opts.search?.trim()) {
|
||||
qb.andWhere("c.company_name ILIKE :term", {
|
||||
term: `%${opts.search.trim()}%`,
|
||||
});
|
||||
}
|
||||
if (opts.unreadOnly) {
|
||||
// Same rule as SupportMessageRepository.baseUnreadQuery: a message from
|
||||
// the other role, newer than this side's cursor. `cursorCol` is chosen
|
||||
// from a closed set below — never caller input.
|
||||
const otherRole =
|
||||
side === SupportAuthorRole.CUSTOMER
|
||||
? SupportAuthorRole.AGENT
|
||||
: SupportAuthorRole.CUSTOMER;
|
||||
const cursorCol =
|
||||
side === SupportAuthorRole.CUSTOMER
|
||||
? "c.customer_last_read_at"
|
||||
: "c.agent_last_read_at";
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.support_messages m
|
||||
WHERE m.conversation_id = c.id
|
||||
AND m.deleted_at IS NULL
|
||||
AND m.author_role = :otherRole
|
||||
AND (${cursorCol} IS NULL OR m.created_at > ${cursorCol})
|
||||
)`,
|
||||
{ otherRole },
|
||||
);
|
||||
}
|
||||
return qb.getManyAndCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
|
||||
@Injectable()
|
||||
export class SupportMessageRepository extends BaseRepository<SupportMessage> {
|
||||
constructor(
|
||||
@InjectRepository(SupportMessage)
|
||||
repo: Repository<SupportMessage>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** All messages of a conversation, oldest first. */
|
||||
async listByConversation(conversationId: string): Promise<SupportMessage[]> {
|
||||
return this.repository.find({
|
||||
where: { conversationId },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread message counts per conversation *for one side*: messages authored by
|
||||
* the other role that are newer than the side's read cursor. Returns a map of
|
||||
* conversationId → count (conversations with 0 unread are absent).
|
||||
*/
|
||||
async unreadCountsByConversation(
|
||||
conversationIds: string[],
|
||||
mySide: SupportAuthorRole,
|
||||
): Promise<Map<string, number>> {
|
||||
if (conversationIds.length === 0) return new Map();
|
||||
const rows = await this.baseUnreadQuery(mySide)
|
||||
.select("m.conversation_id", "conversationId")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.andWhere("m.conversation_id IN (:...ids)", { ids: conversationIds })
|
||||
.groupBy("m.conversation_id")
|
||||
.getRawMany<{ conversationId: string; count: string }>();
|
||||
return new Map(rows.map((r) => [r.conversationId, Number(r.count)]));
|
||||
}
|
||||
|
||||
/** Number of distinct conversations with at least one unread message for the side. */
|
||||
async countUnreadConversations(
|
||||
mySide: SupportAuthorRole,
|
||||
companyId?: string,
|
||||
): Promise<number> {
|
||||
const qb = this.baseUnreadQuery(mySide).select(
|
||||
"COUNT(DISTINCT m.conversation_id)",
|
||||
"count",
|
||||
);
|
||||
if (companyId) {
|
||||
qb.andWhere("c.company_id = :companyId", { companyId });
|
||||
}
|
||||
const row = await qb.getRawOne<{ count: string }>();
|
||||
return Number(row?.count ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for "unread for `mySide`": join the conversation, keep only
|
||||
* messages from the opposite role that are newer than the side's read cursor.
|
||||
*/
|
||||
private baseUnreadQuery(mySide: SupportAuthorRole) {
|
||||
const otherRole =
|
||||
mySide === SupportAuthorRole.CUSTOMER
|
||||
? SupportAuthorRole.AGENT
|
||||
: SupportAuthorRole.CUSTOMER;
|
||||
const cursorCol =
|
||||
mySide === SupportAuthorRole.CUSTOMER
|
||||
? "c.customer_last_read_at"
|
||||
: "c.agent_last_read_at";
|
||||
return this.repository
|
||||
.createQueryBuilder("m")
|
||||
.innerJoin(SupportConversation, "c", "c.id = m.conversation_id")
|
||||
.where("m.deleted_at IS NULL")
|
||||
.andWhere("m.author_role = :otherRole", { otherRole })
|
||||
.andWhere(`(${cursorCol} IS NULL OR m.created_at > ${cursorCol})`);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export * from "./contracts";
|
||||
export * from "./clearance-files.catalog";
|
||||
export * from "./notifications";
|
||||
export * from "./booking-window-ws";
|
||||
export * from "./support-chat";
|
||||
|
||||
export enum TradeDirection {
|
||||
IMPORT = "IMPORT",
|
||||
|
||||
99
packages/types/src/freight/support-chat.ts
Normal file
99
packages/types/src/freight/support-chat.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Shared contracts for the freight in-app customer-support chat.
|
||||
*
|
||||
* There is exactly **one conversation per customer company** — any portal user
|
||||
* of that company sees and continues the same thread, and every backoffice agent
|
||||
* works the same shared inbox (no assignment). The thread has no lifecycle: it
|
||||
* is created lazily by whichever side speaks first and stays open forever.
|
||||
* Messages are text-only for the MVP.
|
||||
*
|
||||
* Because the thread is implied by the caller's company, the portal contract is
|
||||
* addressed as a singleton (`/support/conversation`) and never passes an id.
|
||||
* Agents address threads by id, since they see every company's.
|
||||
*
|
||||
* Mirrors the notification system's contract shape (`notifications.ts`): DTO
|
||||
* interfaces with string dates for the wire, plus frozen WS event/namespace
|
||||
* constants shared by the gateway (emitter) and both web apps (subscribers).
|
||||
*/
|
||||
|
||||
/** Who authored a message — the customer side or a backoffice agent. */
|
||||
export enum SupportAuthorRole {
|
||||
CUSTOMER = "CUSTOMER",
|
||||
AGENT = "AGENT",
|
||||
}
|
||||
|
||||
/** A single chat message on the wire. */
|
||||
export interface SupportMessageDto {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
authorUserId: string;
|
||||
authorRole: SupportAuthorRole;
|
||||
/** Display name of the author, resolved at send time (best-effort). */
|
||||
authorName?: string | null;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A company's conversation on the wire, with denormalized last-message fields. */
|
||||
export interface SupportConversationDto {
|
||||
id: string;
|
||||
companyId: string;
|
||||
companyName?: string | null;
|
||||
/** Null when an agent opened the thread — no customer created it. */
|
||||
createdByUserId?: string | null;
|
||||
lastMessageAt?: string | null;
|
||||
lastMessagePreview?: string | null;
|
||||
lastMessageAuthorRole?: SupportAuthorRole | null;
|
||||
/**
|
||||
* Unread count *for the caller's side* (messages authored by the other role
|
||||
* after the caller's read cursor). Populated on list/detail responses only —
|
||||
* WS payloads carry an unauthoritative 0, so clients must refetch, not trust it.
|
||||
*/
|
||||
unreadCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Post a message. The portal omits the id; the thread is implied by the company. */
|
||||
export interface SendSupportMessageDto {
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Agent opens a thread with a company that has none yet. */
|
||||
export interface StartSupportConversationDto {
|
||||
companyId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a portal send: the thread (created on the fly if this was the first
|
||||
* message) alongside the persisted message.
|
||||
*/
|
||||
export interface SendSupportMessageResult {
|
||||
conversation: SupportConversationDto;
|
||||
message: SupportMessageDto;
|
||||
}
|
||||
|
||||
/** Paginated list envelope for the agent conversations list endpoint. */
|
||||
export interface SupportConversationListResult {
|
||||
items: SupportConversationDto[];
|
||||
count: number;
|
||||
/** Total unread conversations for the caller's side (badge source). */
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/** Socket.io event names pushed server → client on the `support-chat` namespace. */
|
||||
export const SUPPORT_CHAT_WS_EVENTS = {
|
||||
/** A new message was added to a conversation the socket can see. */
|
||||
MESSAGE_NEW: "support:message-new",
|
||||
/** A conversation's metadata changed (last message, or a thread was opened). */
|
||||
CONVERSATION_UPDATED: "support:conversation-updated",
|
||||
} as const;
|
||||
|
||||
/** Socket.io namespace the support-chat gateway listens on. */
|
||||
export const SUPPORT_CHAT_WS_NAMESPACE = "support-chat";
|
||||
|
||||
/** Payload for {@link SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW}. */
|
||||
export interface SupportMessageEvent {
|
||||
conversation: SupportConversationDto;
|
||||
message: SupportMessageDto;
|
||||
}
|
||||
Reference in New Issue
Block a user