mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
@@ -39,6 +39,7 @@ import { TrackingModule } from "./modules/tracking/tracking.module";
|
|||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.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 { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
@@ -160,6 +161,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
SupportChatModule,
|
||||||
FileUploadSettingsModule,
|
FileUploadSettingsModule,
|
||||||
DropdownSettingsModule,
|
DropdownSettingsModule,
|
||||||
ContractTemplatesModule,
|
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,
|
WsAuthService,
|
||||||
NotificationInboxService,
|
NotificationInboxService,
|
||||||
],
|
],
|
||||||
exports: [NotificationInboxService],
|
// WsAuthService is reused by the support-chat gateway for handshake auth.
|
||||||
|
exports: [NotificationInboxService, WsAuthService],
|
||||||
})
|
})
|
||||||
export class NotificationInboxModule {}
|
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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,13 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Settings2,
|
|
||||||
Ship,
|
Ship,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Train,
|
Train,
|
||||||
Truck,
|
Truck,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
LifeBuoy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -132,6 +132,7 @@ import { HealthCheck } from "./features/health/HealthCheck";
|
|||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import { UserManagementRoutes } from "./user-management/route";
|
import { UserManagementRoutes } from "./user-management/route";
|
||||||
import SetPassword from "./shared/components/SetPassword";
|
import SetPassword from "./shared/components/SetPassword";
|
||||||
|
import SupportInboxPage from "./pages/support/SupportInboxPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -178,6 +179,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <Receipt />,
|
icon: <Receipt />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Support",
|
||||||
|
href: "/dashboard/support",
|
||||||
|
icon: <LifeBuoy />,
|
||||||
|
},
|
||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -713,6 +719,7 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="support" element={<SupportInboxPage />} />
|
||||||
<Route path="customers" element={<CustomersPage />} />
|
<Route path="customers" element={<CustomersPage />} />
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type {
|
||||||
|
SendSupportMessageDto,
|
||||||
|
SupportConversationDto,
|
||||||
|
SupportConversationListResult,
|
||||||
|
SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/auth/http";
|
||||||
|
|
||||||
|
export interface ListConversationsParams {
|
||||||
|
search?: string;
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
|
||||||
|
* response interceptor already unwraps the `{ success, data }` envelope, so
|
||||||
|
* `.data` here is the payload itself.
|
||||||
|
*/
|
||||||
|
export const supportApi = {
|
||||||
|
listConversations: async (
|
||||||
|
params: ListConversationsParams = {},
|
||||||
|
): Promise<SupportConversationListResult> => {
|
||||||
|
const { data } = await api.get<SupportConversationListResult>(
|
||||||
|
"/support/agent/conversations",
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
|
||||||
|
const { data } = await api.get<SupportMessageDto[]>(
|
||||||
|
`/support/agent/conversations/${id}/messages`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
sendMessage: async (
|
||||||
|
id: string,
|
||||||
|
body: SendSupportMessageDto,
|
||||||
|
): Promise<SupportMessageDto> => {
|
||||||
|
const { data } = await api.post<SupportMessageDto>(
|
||||||
|
`/support/agent/conversations/${id}/messages`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
/** Open the thread with a company, or hand back the existing one. */
|
||||||
|
startConversation: async (
|
||||||
|
companyId: string,
|
||||||
|
): Promise<SupportConversationDto> => {
|
||||||
|
const { data } = await api.post<SupportConversationDto>(
|
||||||
|
"/support/agent/conversations",
|
||||||
|
{ companyId },
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
markRead: async (id: string): Promise<{ unreadCount: number }> => {
|
||||||
|
const { data } = await api.post<{ unreadCount: number }>(
|
||||||
|
`/support/agent/conversations/${id}/read`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await api.get<{ unreadCount: number }>(
|
||||||
|
"/support/agent/unread-count",
|
||||||
|
);
|
||||||
|
return data.unreadCount;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { supportApi, type ListConversationsParams } from "./supportApi";
|
||||||
|
|
||||||
|
export const SUPPORT_KEY = ["support"] as const;
|
||||||
|
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
|
||||||
|
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
|
||||||
|
export const supportMessagesKey = (id: string) =>
|
||||||
|
["support", "messages", id] as const;
|
||||||
|
|
||||||
|
/** Shared inbox: every thread, filterable by unread + company-name search. */
|
||||||
|
export function useConversations(params: ListConversationsParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
||||||
|
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages(conversationId: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: supportMessagesKey(conversationId ?? ""),
|
||||||
|
queryFn: () => supportApi.listMessages(conversationId as string),
|
||||||
|
enabled: !!conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupportUnreadCount(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_UNREAD_KEY,
|
||||||
|
queryFn: () => supportApi.unreadCount(),
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSendMessage(conversationId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: string) =>
|
||||||
|
supportApi.sendMessage(conversationId, { body }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start chatting with a company. Idempotent server-side, so picking a company
|
||||||
|
* that already has a thread just selects it.
|
||||||
|
*/
|
||||||
|
export function useStartConversation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (companyId: string) => supportApi.startConversation(companyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkConversationRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => supportApi.markRead(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
SUPPORT_CHAT_WS_EVENTS,
|
||||||
|
SUPPORT_CHAT_WS_NAMESPACE,
|
||||||
|
type SupportConversationDto,
|
||||||
|
type SupportMessageEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SUPPORT_CONVERSATIONS_KEY,
|
||||||
|
SUPPORT_UNREAD_KEY,
|
||||||
|
supportMessagesKey,
|
||||||
|
} from "./useSupport";
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes the signed-in agent to live support-chat pushes for the whole
|
||||||
|
* shared inbox. Any new message or conversation change refreshes the affected
|
||||||
|
* thread, the inbox list, and the unread badge; `onMessage` fires for toasts.
|
||||||
|
*/
|
||||||
|
export function useSupportSocket(
|
||||||
|
enabled: boolean,
|
||||||
|
onMessage?: (event: SupportMessageEvent) => void,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: supportMessagesKey(event.message.conversationId),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
onMessageRef.current?.(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||||
|
(_conversation: SupportConversationDto) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import {
|
||||||
|
SupportAuthorRole,
|
||||||
|
type SupportConversationDto,
|
||||||
|
type SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Building2, Headset, Plus, Search, Send, User } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useConversations,
|
||||||
|
useMarkConversationRead,
|
||||||
|
useMessages,
|
||||||
|
useSendMessage,
|
||||||
|
useStartConversation,
|
||||||
|
} from "@/features/support/useSupport";
|
||||||
|
import { useSupportSocket } from "@/features/support/useSupportSocket";
|
||||||
|
import { customersService } from "@/services/customers.service";
|
||||||
|
|
||||||
|
type ReadFilter = "ALL" | "UNREAD";
|
||||||
|
|
||||||
|
function formatTime(iso?: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
return sameDay
|
||||||
|
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SupportInboxPage() {
|
||||||
|
const [readFilter, setReadFilter] = useState<ReadFilter>("ALL");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useConversations({
|
||||||
|
search,
|
||||||
|
unreadOnly: readFilter === "UNREAD",
|
||||||
|
});
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
useSupportSocket(true, (event) => {
|
||||||
|
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
|
||||||
|
toast(
|
||||||
|
`New message from ${event.conversation.companyName ?? "a customer"}`,
|
||||||
|
{ icon: "💬" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = useMemo(
|
||||||
|
() => items.find((c) => c.id === selectedId) ?? null,
|
||||||
|
[items, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p="md">
|
||||||
|
<Group mb="md" gap="sm">
|
||||||
|
<ThemeIcon size="lg" radius="md" color="edr-green" variant="light">
|
||||||
|
<Headset size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box>
|
||||||
|
<Text fw={700} size="lg">
|
||||||
|
Customer Support
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Shared inbox — chat with customers in real time
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
height: "calc(100vh - 190px)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Conversation list ── */}
|
||||||
|
<Stack
|
||||||
|
gap={0}
|
||||||
|
style={{
|
||||||
|
width: 340,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
onClick={() => setPickerOpen(true)}
|
||||||
|
mb="sm"
|
||||||
|
>
|
||||||
|
New chat
|
||||||
|
</Button>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search company"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
radius="md"
|
||||||
|
mb="sm"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
size="xs"
|
||||||
|
value={readFilter}
|
||||||
|
onChange={(v) => setReadFilter(v as ReadFilter)}
|
||||||
|
data={[
|
||||||
|
{ label: "All", value: "ALL" },
|
||||||
|
{ label: "Unread", value: "UNREAD" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<ScrollArea style={{ flex: 1 }} type="hover">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||||
|
{readFilter === "UNREAD" ? "Nothing unread." : "No conversations."}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
items.map((c) => (
|
||||||
|
<InboxRow
|
||||||
|
key={c.id}
|
||||||
|
c={c}
|
||||||
|
active={c.id === selectedId}
|
||||||
|
onClick={() => setSelectedId(c.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* ── Thread ── */}
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{selected ? (
|
||||||
|
<ConversationThread conversation={selected} />
|
||||||
|
) : (
|
||||||
|
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||||
|
<Headset size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm">Select a conversation, or start a new chat.</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<CompanyPicker
|
||||||
|
opened={pickerOpen}
|
||||||
|
onClose={() => setPickerOpen(false)}
|
||||||
|
onStarted={(id) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a company to chat with. Starting is idempotent server-side, so choosing a
|
||||||
|
* company that already has a thread simply selects it rather than erroring.
|
||||||
|
*/
|
||||||
|
function CompanyPicker({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onStarted,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onStarted: (conversationId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||||
|
const start = useStartConversation();
|
||||||
|
|
||||||
|
const { data: companies, isLoading } = useQuery({
|
||||||
|
queryKey: ["companies", "list"],
|
||||||
|
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
||||||
|
enabled: opened,
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
(companies?.items ?? []).map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name || c.email || c.tin || c.id,
|
||||||
|
})),
|
||||||
|
[companies],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!companyId) return;
|
||||||
|
const conversation = await start.mutateAsync(companyId);
|
||||||
|
setCompanyId(null);
|
||||||
|
onStarted(conversation.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label="Customer"
|
||||||
|
placeholder={isLoading ? "Loading companies…" : "Search for a company"}
|
||||||
|
data={options}
|
||||||
|
value={companyId}
|
||||||
|
onChange={setCompanyId}
|
||||||
|
searchable
|
||||||
|
nothingFoundMessage="No companies match."
|
||||||
|
disabled={isLoading}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
loading={start.isPending}
|
||||||
|
disabled={!companyId}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
Start chatting
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InboxRow({
|
||||||
|
c,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
c: SupportConversationDto;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const unread = c.unreadCount > 0;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={onClick}
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "none",
|
||||||
|
borderLeft: active
|
||||||
|
? "3px solid var(--mantine-color-edr-green-6)"
|
||||||
|
: "3px solid transparent",
|
||||||
|
background: active
|
||||||
|
? "var(--mantine-color-edr-green-0)"
|
||||||
|
: unread
|
||||||
|
? "var(--mantine-color-gray-0)"
|
||||||
|
: "transparent",
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||||
|
<Group gap={6} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Building2 size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text fw={unread ? 700 : 600} size="sm" truncate style={{ flex: 1 }}>
|
||||||
|
{c.companyName ?? "Unknown company"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||||
|
{formatTime(c.lastMessageAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xs" mt={4}>
|
||||||
|
<Text size="xs" c="dimmed" truncate style={{ flex: 1 }}>
|
||||||
|
{c.lastMessageAuthorRole === SupportAuthorRole.AGENT ? "You: " : ""}
|
||||||
|
{c.lastMessagePreview ?? "No messages yet"}
|
||||||
|
</Text>
|
||||||
|
{unread && (
|
||||||
|
<Badge size="sm" circle color="edr-green">
|
||||||
|
{c.unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationThread({
|
||||||
|
conversation,
|
||||||
|
}: {
|
||||||
|
conversation: SupportConversationDto;
|
||||||
|
}) {
|
||||||
|
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||||
|
const send = useSendMessage(conversation.id);
|
||||||
|
const markRead = useMarkConversationRead();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
markRead.mutate(conversation.id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conversation.id, messages?.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||||
|
}, [messages?.length, conversation.id]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setDraft("");
|
||||||
|
await send.mutateAsync(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={0} h="100%">
|
||||||
|
{/* Header */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="md"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size="md">
|
||||||
|
<Building2 size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={700} truncate>
|
||||||
|
{conversation.companyName ?? "Unknown company"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : (messages ?? []).length === 0 ? (
|
||||||
|
<Text c="dimmed" size="sm" ta="center" p="xl">
|
||||||
|
No messages yet — say hello.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm" p="md">
|
||||||
|
{(messages ?? []).map((m) => (
|
||||||
|
<AgentBubble key={m.id} m={m} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||||
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
|
placeholder="Type your message… (Enter to send, Shift+Enter for newline)"
|
||||||
|
autosize
|
||||||
|
minRows={1}
|
||||||
|
maxRows={5}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
size={38}
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
loading={send.isPending}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||||
|
const mine = m.authorRole === SupportAuthorRole.AGENT;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify={mine ? "flex-end" : "flex-start"}
|
||||||
|
wrap="nowrap"
|
||||||
|
align="flex-end"
|
||||||
|
gap="xs"
|
||||||
|
>
|
||||||
|
{!mine && (
|
||||||
|
<Avatar size="sm" radius="xl" color="gray" variant="filled">
|
||||||
|
<User size={14} />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Box style={{ maxWidth: "70%" }}>
|
||||||
|
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
|
||||||
|
{mine ? m.authorName || "You" : m.authorName || "Customer"}
|
||||||
|
</Text>
|
||||||
|
<Paper
|
||||||
|
px="sm"
|
||||||
|
py={8}
|
||||||
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
background: mine
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "var(--mantine-color-gray-1)",
|
||||||
|
color: mine ? "white" : "var(--mantine-color-dark-7)",
|
||||||
|
borderBottomRightRadius: mine ? 4 : undefined,
|
||||||
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{m.body}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||||
|
{formatTime(m.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||||
|
import SupportWidget from "@/features/support/SupportWidget";
|
||||||
|
|
||||||
export interface SidebarItem {
|
export interface SidebarItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -781,6 +782,9 @@ export function AppLayout({
|
|||||||
{children}
|
{children}
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
|
|
||||||
|
{/* Floating customer-support chat launcher. */}
|
||||||
|
<SupportWidget />
|
||||||
|
|
||||||
{/* Create-profile modal — opens when switching to a mode the company
|
{/* Create-profile modal — opens when switching to a mode the company
|
||||||
doesn't have a profile for yet. */}
|
doesn't have a profile for yet. */}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Headset, Send, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useConversation,
|
||||||
|
useMarkConversationRead,
|
||||||
|
useMessages,
|
||||||
|
useSendMessage,
|
||||||
|
} from "./useSupport";
|
||||||
|
|
||||||
|
function formatTime(iso?: string | null): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const d = new Date(iso);
|
||||||
|
const now = new Date();
|
||||||
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
|
return sameDay
|
||||||
|
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: d.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole support surface: one ongoing thread with the EDR team. There is
|
||||||
|
* nothing to pick and nothing to open — the company has a single conversation,
|
||||||
|
* created server-side the moment the first message is sent.
|
||||||
|
*/
|
||||||
|
export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||||
|
const { data: conversation } = useConversation();
|
||||||
|
const { data: messages, isLoading } = useMessages();
|
||||||
|
const send = useSendMessage();
|
||||||
|
const markRead = useMarkConversationRead();
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Mark read on open + whenever new messages arrive. No-op before the thread
|
||||||
|
// exists, so opening the panel never creates one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (conversation) markRead.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [conversation?.id, messages?.length]);
|
||||||
|
|
||||||
|
// Auto-scroll to newest.
|
||||||
|
useEffect(() => {
|
||||||
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||||
|
}, [messages?.length]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const body = draft.trim();
|
||||||
|
if (!body) return;
|
||||||
|
setDraft("");
|
||||||
|
await send.mutateAsync(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = !isLoading && (messages ?? []).length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
shadow="xl"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: 384,
|
||||||
|
height: 560,
|
||||||
|
maxHeight: "calc(100vh - 120px)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-edr-green-7), var(--mantine-color-edr-green-5))",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<ThemeIcon variant="white" radius="xl" size="lg" color="edr-green">
|
||||||
|
<Headset size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} size="sm" truncate>
|
||||||
|
Support
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" opacity={0.85} truncate>
|
||||||
|
We usually reply within a few minutes
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<ActionIcon variant="transparent" color="white" onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" p="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<Stack align="center" gap="xs" px="lg" py={48} c="dimmed">
|
||||||
|
<ThemeIcon variant="light" color="edr-green" radius="xl" size={48}>
|
||||||
|
<Headset size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" ta="center">
|
||||||
|
Send us a message and our team will help you out.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap="sm" p="md">
|
||||||
|
{(messages ?? []).map((m) => (
|
||||||
|
<MessageBubble key={m.id} m={m} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||||
|
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||||
|
<Textarea
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
|
placeholder="Type a message…"
|
||||||
|
autosize
|
||||||
|
minRows={1}
|
||||||
|
maxRows={4}
|
||||||
|
radius="md"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
size={38}
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
variant="filled"
|
||||||
|
loading={send.isPending}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({ m }: { m: SupportMessageDto }) {
|
||||||
|
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify={mine ? "flex-end" : "flex-start"}
|
||||||
|
wrap="nowrap"
|
||||||
|
align="flex-end"
|
||||||
|
gap="xs"
|
||||||
|
>
|
||||||
|
{!mine && (
|
||||||
|
<Avatar size="sm" radius="xl" color="edr-green" variant="filled">
|
||||||
|
<Headset size={14} />
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<Box style={{ maxWidth: "78%" }}>
|
||||||
|
{!mine && (
|
||||||
|
<Text size="xs" c="dimmed" mb={2} ml={4}>
|
||||||
|
{m.authorName || "Support agent"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Paper
|
||||||
|
px="sm"
|
||||||
|
py={8}
|
||||||
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
background: mine
|
||||||
|
? "var(--mantine-color-edr-green-6)"
|
||||||
|
: "var(--mantine-color-gray-1)",
|
||||||
|
color: mine ? "white" : "var(--mantine-color-dark-7)",
|
||||||
|
borderBottomRightRadius: mine ? 4 : undefined,
|
||||||
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||||
|
{m.body}
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||||
|
{formatTime(m.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupportPanel;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { SupportAuthorRole } from "@edr/types";
|
||||||
|
import { Affix, Indicator, Transition } from "@mantine/core";
|
||||||
|
import { Headset } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
import { SupportPanel } from "./SupportPanel";
|
||||||
|
import { useSupportUnreadCount } from "./useSupport";
|
||||||
|
import { useSupportSocket } from "./useSupportSocket";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating customer-support launcher, mounted in the authenticated app shell.
|
||||||
|
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
||||||
|
* fresh and toast the customer when the panel is closed.
|
||||||
|
*/
|
||||||
|
export function SupportWidget() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { data: unread = 0 } = useSupportUnreadCount(isAuthenticated);
|
||||||
|
|
||||||
|
useSupportSocket(isAuthenticated, (event) => {
|
||||||
|
// Only the customer-visible side matters here; agent replies arrive as AGENT.
|
||||||
|
if (!open && event.message.authorRole === SupportAuthorRole.AGENT) {
|
||||||
|
toast(
|
||||||
|
`Support replied: ${event.message.body.slice(0, 60)}${
|
||||||
|
event.message.body.length > 60 ? "…" : ""
|
||||||
|
}`,
|
||||||
|
{ icon: "💬" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAuthenticated) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Affix position={{ bottom: 24, right: 24 }} zIndex={300}>
|
||||||
|
<Transition mounted={open} transition="pop-bottom-right" duration={200}>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles} className="mb-3">
|
||||||
|
<SupportPanel onClose={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition mounted={!open} transition="pop" duration={150}>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles} className="flex justify-end">
|
||||||
|
<Indicator
|
||||||
|
label={unread > 9 ? "9+" : unread}
|
||||||
|
size={20}
|
||||||
|
offset={8}
|
||||||
|
color="red"
|
||||||
|
disabled={unread === 0}
|
||||||
|
processing
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Open support chat"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="group relative flex cursor-pointer items-center gap-2.5 rounded-full bg-linear-135 from-edr-primary-dark to-edr-primary py-2 pr-2 pl-2 text-white shadow-[0_10px_28px_rgba(13,92,44,0.4)] transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-edr-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||||
|
>
|
||||||
|
{/* Faint breathing ring; the unread badge already pulses, so stand down then. */}
|
||||||
|
{unread === 0 && (
|
||||||
|
<span className="pointer-events-none absolute -inset-1 animate-pulse rounded-full ring-2 ring-edr-primary/50 motion-reduce:animate-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="relative grid size-[42px] shrink-0 place-items-center rounded-full bg-white/20">
|
||||||
|
<Headset size={22} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="relative hidden text-left leading-tight sm:block">
|
||||||
|
<span className="block text-sm font-semibold whitespace-nowrap">
|
||||||
|
👋 Need help?
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] whitespace-nowrap opacity-85">
|
||||||
|
Chat with our team
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Indicator>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Affix>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SupportWidget;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type {
|
||||||
|
SendSupportMessageResult,
|
||||||
|
SupportConversationDto,
|
||||||
|
SupportMessageDto,
|
||||||
|
} from "@edr/types";
|
||||||
|
|
||||||
|
import { client } from "@/utils/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal support-chat REST calls. My company has exactly one thread, so nothing
|
||||||
|
* here takes a conversation id — the server derives it from the caller.
|
||||||
|
*
|
||||||
|
* The portal axios `client` returns the raw response and the API wraps payloads
|
||||||
|
* in a `{ success, data }` envelope, so we unwrap `.data.data` (same convention
|
||||||
|
* as the other portal services).
|
||||||
|
*/
|
||||||
|
export const supportApi = {
|
||||||
|
/** Null until someone has actually sent a message. */
|
||||||
|
getConversation: async (): Promise<SupportConversationDto | null> => {
|
||||||
|
const { data } = await client.get("/api/support/conversation");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
listMessages: async (): Promise<SupportMessageDto[]> => {
|
||||||
|
const { data } = await client.get("/api/support/conversation/messages");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
|
||||||
|
const { data } = await client.post("/api/support/conversation/messages", {
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
markRead: async (): Promise<{ unreadCount: number }> => {
|
||||||
|
const { data } = await client.post("/api/support/conversation/read");
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
unreadCount: async (): Promise<number> => {
|
||||||
|
const { data } = await client.get("/api/support/unread-count");
|
||||||
|
return data.data.unreadCount;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { SupportConversationDto } from "@edr/types";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { supportApi } from "./supportApi";
|
||||||
|
|
||||||
|
export const SUPPORT_KEY = ["support"] as const;
|
||||||
|
export const SUPPORT_CONVERSATION_KEY = ["support", "conversation"] as const;
|
||||||
|
export const SUPPORT_MESSAGES_KEY = ["support", "messages"] as const;
|
||||||
|
export const SUPPORT_UNREAD_KEY = ["support", "unread"] as const;
|
||||||
|
|
||||||
|
/** My company's support thread — null until the first message is sent. */
|
||||||
|
export function useConversation(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_CONVERSATION_KEY,
|
||||||
|
queryFn: () => supportApi.getConversation(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The thread's messages, oldest first. Empty until the thread exists. */
|
||||||
|
export function useMessages(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_MESSAGES_KEY,
|
||||||
|
queryFn: () => supportApi.listMessages(),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupportUnreadCount(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SUPPORT_UNREAD_KEY,
|
||||||
|
queryFn: () => supportApi.unreadCount(),
|
||||||
|
enabled,
|
||||||
|
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send as the customer. Opens the thread server-side if this is the first one. */
|
||||||
|
export function useSendMessage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: string) => supportApi.sendMessage(body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMarkConversationRead() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => supportApi.markRead(),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SupportConversationDto };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
SUPPORT_CHAT_WS_EVENTS,
|
||||||
|
SUPPORT_CHAT_WS_NAMESPACE,
|
||||||
|
type SupportConversationDto,
|
||||||
|
type SupportMessageEvent,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { io } from "socket.io-client";
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SUPPORT_CONVERSATION_KEY,
|
||||||
|
SUPPORT_MESSAGES_KEY,
|
||||||
|
SUPPORT_UNREAD_KEY,
|
||||||
|
} from "./useSupport";
|
||||||
|
|
||||||
|
function getAuthToken(): string | undefined {
|
||||||
|
return document.cookie
|
||||||
|
.split("; ")
|
||||||
|
.find((row) => row.startsWith("auth-token="))
|
||||||
|
?.split("=")[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket namespace lives at the server root, not under the `/api` REST
|
||||||
|
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||||
|
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to live support-chat pushes for the signed-in customer. The company
|
||||||
|
* only has one thread, so any push refreshes it wholesale — messages, the
|
||||||
|
* conversation itself, and the unread badge — and fires `onMessage` (the widget
|
||||||
|
* shows a toast when the panel is closed).
|
||||||
|
*/
|
||||||
|
export function useSupportSocket(
|
||||||
|
enabled: boolean,
|
||||||
|
onMessage?: (event: SupportMessageEvent) => void,
|
||||||
|
) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const socket = io(`${SOCKET_ORIGIN}/${SUPPORT_CHAT_WS_NAMESPACE}`, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket"],
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
onMessageRef.current?.(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on(
|
||||||
|
SUPPORT_CHAT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||||
|
(_conversation: SupportConversationDto) => {
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||||
|
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off();
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, qc]);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export * from "./contracts";
|
|||||||
export * from "./clearance-files.catalog";
|
export * from "./clearance-files.catalog";
|
||||||
export * from "./notifications";
|
export * from "./notifications";
|
||||||
export * from "./booking-window-ws";
|
export * from "./booking-window-ws";
|
||||||
|
export * from "./support-chat";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = "IMPORT",
|
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