From 1fd46afaaa2e18943d50d7f80c3c4ab31042813c Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 17 Jul 2026 10:59:25 +0000 Subject: [PATCH] fix: added chat to the freight api --- apps/edr-freight-api/src/app.module.ts | 2 + .../2310000000000-CreateSupportChat.ts | 78 ++++ .../notification-inbox.module.ts | 3 +- .../dto/list-conversations-query.dto.ts | 34 ++ .../support-chat/dto/send-message.dto.ts | 11 + .../dto/start-conversation.dto.ts | 10 + .../entities/support-conversation.entity.ts | 54 +++ .../entities/support-message.entity.ts | 24 ++ .../support-chat-agent.controller.ts | 76 ++++ .../support-chat/support-chat.controller.ts | 59 +++ .../support-chat/support-chat.gateway.ts | 122 ++++++ .../support-chat/support-chat.module.ts | 35 ++ .../support-chat/support-chat.service.ts | 367 ++++++++++++++++++ .../support-conversation.repository.ts | 75 ++++ .../support-message.repository.ts | 82 ++++ packages/types/src/freight/index.ts | 1 + packages/types/src/freight/support-chat.ts | 99 +++++ 17 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts create mode 100644 apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts create mode 100644 packages/types/src/freight/support-chat.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 72a5952d7..82e70d3c5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts new file mode 100644 index 000000000..17db8a4e1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2310000000000-CreateSupportChat.ts @@ -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 { + 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 { + 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`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index d30ebe3a9..e9f3af958 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts new file mode 100644 index 000000000..a4ebab160 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/list-conversations-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts new file mode 100644 index 000000000..89178ef63 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/send-message.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts new file mode 100644 index 000000000..736ff13aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/dto/start-conversation.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts new file mode 100644 index 000000000..4e74364d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-conversation.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts new file mode 100644 index 000000000..443a1694f --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/entities/support-message.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts new file mode 100644 index 000000000..efeb918be --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat-agent.controller.ts @@ -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), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts new file mode 100644 index 000000000..8d20ee91f --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.controller.ts @@ -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), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts new file mode 100644 index 000000000..e2e2875bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.gateway.ts @@ -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:` 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 { + 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; + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts new file mode 100644 index 000000000..34accab44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts new file mode 100644 index 000000000..70de1ce11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-chat.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts new file mode 100644 index 000000000..10bdfcb4a --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-conversation.repository.ts @@ -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 { + constructor( + @InjectRepository(SupportConversation) + repo: Repository, + ) { + super(repo); + } + + /** The company's thread, or null if neither side has spoken yet. */ + async findByCompanyId(companyId: string): Promise { + 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(); + } +} diff --git a/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts new file mode 100644 index 000000000..827035320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/support-chat/support-message.repository.ts @@ -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 { + constructor( + @InjectRepository(SupportMessage) + repo: Repository, + ) { + super(repo); + } + + /** All messages of a conversation, oldest first. */ + async listByConversation(conversationId: string): Promise { + 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> { + 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 { + 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})`); + } +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dc973d9c1..aafbfe660 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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", diff --git a/packages/types/src/freight/support-chat.ts b/packages/types/src/freight/support-chat.ts new file mode 100644 index 000000000..84b9a7262 --- /dev/null +++ b/packages/types/src/freight/support-chat.ts @@ -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; +}