mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #788 from Tria-plc/freight/feat/chat-app
Freight/feat/chat app added attachment
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Let a support message carry files instead of text.
|
||||
*
|
||||
* No new table: chat attachments reuse the polymorphic `freight.files` record
|
||||
* with `resource = 'support_message'` and `resource_id = <message id>`, the same
|
||||
* way bookings/contracts/companies already store theirs.
|
||||
*
|
||||
* The only schema change is dropping NOT NULL from `support_messages.body`, so
|
||||
* an attachment-only message can say "there is no text" rather than smuggling
|
||||
* that fact through an empty string. DROP NOT NULL is a catalog-only change in
|
||||
* Postgres — no table rewrite, no long lock — so this is safe on a live table.
|
||||
*
|
||||
* The partial index on (resource, resource_id) is what makes hydrating a page of
|
||||
* messages one indexed lookup instead of a scan of every file row in the system.
|
||||
*/
|
||||
export class SupportChatAttachments2320000000000 implements MigrationInterface {
|
||||
name = "SupportChatAttachments2320000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.support_messages
|
||||
ALTER COLUMN body DROP NOT NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_FILES_RESOURCE_LOOKUP"
|
||||
ON freight.files (resource, resource_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight."IDX_FILES_RESOURCE_LOOKUP"
|
||||
`);
|
||||
|
||||
// Re-imposing NOT NULL would fail on any attachment-only message written
|
||||
// while this migration was applied. Backfill those to '' first so the
|
||||
// rollback is deterministic rather than dependent on production data.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.support_messages SET body = '' WHERE body IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.support_messages
|
||||
ALTER COLUMN body SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
@@ -23,13 +30,16 @@ export class FilesController {
|
||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||
// caller's booking/company before streaming).
|
||||
// caller's booking/company before streaming). Until that lands, any resource
|
||||
// whose files are cross-tenant sensitive must opt OUT of this route and expose
|
||||
// its own checked endpoint — see the support_message case below.
|
||||
@ApiOperation({
|
||||
summary: "Stream a file by ID",
|
||||
description:
|
||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||
"No resource context (e.g. booking ID) required. Serves inline by default so " +
|
||||
"the browser can preview it; pass ?download=1 to force a download.",
|
||||
"the browser can preview it; pass ?download=1 to force a download. " +
|
||||
"Support-chat attachments are NOT served here — use GET /support/attachments/:fileId.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
@@ -41,7 +51,19 @@ export class FilesController {
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.filesService.streamById(fileId);
|
||||
const record = await this.filesService.findById(fileId);
|
||||
|
||||
// Chat attachments are cross-tenant sensitive and this route has no
|
||||
// ownership check, so a leaked/guessed UUID would hand one company's file to
|
||||
// another. SupportAttachmentController scopes the caller to the owning
|
||||
// thread; refuse here rather than quietly serving the bytes.
|
||||
if (record.resource === SUPPORT_ATTACHMENT_RESOURCE) {
|
||||
throw new ForbiddenException(
|
||||
"Support chat attachments must be fetched via GET /support/attachments/:fileId.",
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.filesService.streamById(fileId);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
const disposition = forceDownload ? "attachment" : "inline";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { In, Repository } from "typeorm";
|
||||
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@@ -18,6 +18,21 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
return this.repository.find({ where: { resourceId, resource } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch sibling of {@link findByResource} for hydrating a page of resources at
|
||||
* once (a thread of chat messages, say) instead of one query per row.
|
||||
*/
|
||||
async findByResourceIds(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<FileRecord[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
return this.repository.find({
|
||||
where: { resourceId: In(resourceIds), resource },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
@@ -78,8 +79,19 @@ export class FilesService {
|
||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||
// human-readable name is preserved separately on the record below.
|
||||
const safeName = sanitizeObjectName(file.originalname);
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${safeName}`;
|
||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
// The random segment is load-bearing, not decoration. `Date.now()` alone is
|
||||
// NOT unique across a batch: callers upload with Promise.all, every callback
|
||||
// runs to its first await in the same tick, so they all read the same
|
||||
// millisecond. Two files with one name in one batch — e.g. pasting two
|
||||
// screenshots, which browsers both call "image.png" — would build identical
|
||||
// keys, and the second putObject would overwrite the first while both rows
|
||||
// persisted pointing at the same object.
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||
const url = await this.minioService.uploadFile(
|
||||
objectName,
|
||||
file.buffer,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
return this.filesRepository.create({
|
||||
resourceId,
|
||||
@@ -175,6 +187,27 @@ export class FilesService {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files for many resources of one kind, grouped by resource id. Resources with
|
||||
* no files are absent from the map (callers should default to `[]`).
|
||||
*/
|
||||
async findByResourceIdsGrouped(
|
||||
resourceIds: string[],
|
||||
resource: string,
|
||||
): Promise<Map<string, FileRecord[]>> {
|
||||
const records = await this.filesRepository.findByResourceIds(
|
||||
resourceIds,
|
||||
resource,
|
||||
);
|
||||
const grouped = new Map<string, FileRecord[]>();
|
||||
for (const record of records) {
|
||||
const bucket = grouped.get(record.resourceId);
|
||||
if (bucket) bucket.push(record);
|
||||
else grouped.set(record.resourceId, [record]);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for a stored file's raw MinIO URL. The persisted
|
||||
* `url` is an un-signed object path that a browser cannot fetch directly;
|
||||
@@ -190,7 +223,11 @@ export class FilesService {
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
||||
const record = await this.filesRepository.findByCode(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
);
|
||||
if (!record)
|
||||
throw new NotFoundException(
|
||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||
@@ -198,7 +235,9 @@ export class FilesService {
|
||||
return record;
|
||||
}
|
||||
|
||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
async streamById(
|
||||
id: string,
|
||||
): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from "@edr/types";
|
||||
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface";
|
||||
|
||||
/** Multipart field name carrying chat files. */
|
||||
export const SUPPORT_ATTACHMENT_FIELD = "attachments";
|
||||
|
||||
/**
|
||||
* Multer-level caps for the chat send routes.
|
||||
*
|
||||
* These duplicate the checks in `SupportChatService.assertSendable` on purpose,
|
||||
* and are not a substitute for them: Multer stops reading the socket once a part
|
||||
* exceeds `fileSize`, so an oversized upload is cut off mid-stream instead of
|
||||
* being buffered into memory and rejected after the fact. The service-level
|
||||
* check is what produces the readable error message and covers callers that
|
||||
* don't come through this interceptor.
|
||||
*/
|
||||
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||
|
||||
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||
"of messages immediately OLDER than the cursor. Omit for the newest page.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { SendSupportMessageDto as ISendSupportMessageDto } from "@edr/types";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MaxLength, MinLength } from "class-validator";
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class SendMessageDto implements ISendSupportMessageDto {
|
||||
@ApiProperty({ description: "Message text." })
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Message text. Optional only when the request carries attachments — the " +
|
||||
"service rejects a message that is neither text nor files.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
body!: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ export class SupportMessage extends BaseEntity {
|
||||
@Column({ name: "author_name", type: "varchar", length: 200, nullable: true })
|
||||
authorName?: string | null;
|
||||
|
||||
@Column({ name: "body", type: "text" })
|
||||
body!: string;
|
||||
/**
|
||||
* NULL for an attachment-only message. Nullable rather than "" so the absence
|
||||
* of text is representable instead of guessed at; the DTO maps NULL → "".
|
||||
*/
|
||||
@Column({ name: "body", type: "text", nullable: true })
|
||||
body?: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Keyset cursor for paging a thread backwards from newest.
|
||||
*
|
||||
* The cursor is just a message id. The sort key is the pair `(created_at, id)` —
|
||||
* two messages can share a timestamp, and a cursor on a non-unique key either
|
||||
* re-serves or skips the tied rows — but the *timestamp half is never sent over
|
||||
* the wire*, because it cannot survive the trip.
|
||||
*
|
||||
* `support_messages.created_at` is `timestamptz(6)`; a JS `Date` holds only
|
||||
* milliseconds, so the value TypeORM hands back is already truncated. Encoding
|
||||
* that into the cursor and comparing against it would silently skip every row
|
||||
* sharing the cursor's millisecond but earlier within it (`.254100` is not
|
||||
* `< .254000`) — those rows would never appear on any page. Sending the id alone
|
||||
* and letting Postgres look the real `(created_at, id)` up keeps the comparison
|
||||
* at full precision on the server, where it was never lossy.
|
||||
*
|
||||
* Opaque on purpose (base64): clients must treat it as a token, so the sort key
|
||||
* can change without a contract change.
|
||||
*
|
||||
* The passenger API's twin encodes a timestamp because its column is
|
||||
* `TIMESTAMP(3)` — millisecond, matching JS exactly — so it has no such loss.
|
||||
* The two formats are deliberately NOT interchangeable; each app reads only its
|
||||
* own cursors.
|
||||
*/
|
||||
export function encodeMessageCursor(id: string): string {
|
||||
return Buffer.from(id, "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||
*/
|
||||
export function decodeMessageCursor(raw: string): string {
|
||||
let id: string;
|
||||
try {
|
||||
id = Buffer.from(raw, "base64url").toString("utf8");
|
||||
} catch {
|
||||
throw new BadRequestException("Malformed pagination cursor.");
|
||||
}
|
||||
|
||||
// The id goes into a parameterized query, but validate the shape anyway: a
|
||||
// non-uuid can only be a mangled cursor, and failing loudly here beats an
|
||||
// empty page that reads as "start of conversation".
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
||||
) {
|
||||
throw new BadRequestException("Malformed pagination cursor.");
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SUPPORT_ATTACHMENT_RESOURCE } from "@edr/types";
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
/**
|
||||
* Authenticated download for chat attachments.
|
||||
*
|
||||
* This exists instead of reusing `GET /files/:fileId` because that route streams
|
||||
* any file to any authenticated caller who knows its UUID — fine-ish for a
|
||||
* booking document the caller already had a link to, not fine for chat, where
|
||||
* one customer guessing another's file id would be a cross-tenant leak. That
|
||||
* route now refuses `support_message` files outright and points here.
|
||||
*
|
||||
* Inline previews still use the short-lived signed URL on the message DTO — a
|
||||
* browser `<img>` can't send a Bearer token. This route is for explicit
|
||||
* downloads and for clients that would rather stream through the API.
|
||||
*/
|
||||
@ApiTags("support-chat")
|
||||
@ApiBearerAuth()
|
||||
@Controller("support/attachments")
|
||||
export class SupportAttachmentController {
|
||||
constructor(
|
||||
private readonly files: FilesService,
|
||||
private readonly chat: SupportChatService,
|
||||
) {}
|
||||
|
||||
@Get(":fileId")
|
||||
@ApiOperation({
|
||||
summary: "Download a support chat attachment",
|
||||
description:
|
||||
"Streams the file only if the caller is backoffice staff or belongs to the " +
|
||||
"company that owns the thread the attachment was posted in.",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
required: false,
|
||||
description: "Set to 1/true to force a download instead of inline preview.",
|
||||
})
|
||||
async download(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const record = await this.files.findById(fileId);
|
||||
|
||||
// Don't let this route become a second general-purpose file endpoint: it can
|
||||
// only vouch for chat attachments, so anything else is a 404 (not a 403 —
|
||||
// no reason to confirm the id exists).
|
||||
if (record.resource !== SUPPORT_ATTACHMENT_RESOURCE) {
|
||||
throw new NotFoundException(`File ${fileId} not found`);
|
||||
}
|
||||
|
||||
const allowed = await this.chat.canUserAccessMessage(
|
||||
record.resourceId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
"This attachment belongs to another company's conversation.",
|
||||
);
|
||||
}
|
||||
|
||||
const { stream } = await this.files.streamById(fileId);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
|
||||
res.setHeader("Content-Type", record.mimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`${forceDownload ? "attachment" : "inline"}; filename="${record.name}"`,
|
||||
);
|
||||
// Private only — this response is scoped to one caller's authorization, so a
|
||||
// shared cache must never reuse it for the next person asking.
|
||||
res.setHeader("Cache-Control", "private, max-age=300");
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SupportAuthorRole,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -8,14 +11,22 @@ import {
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from "./attachment-upload.options";
|
||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { StartConversationDto } from "./dto/start-conversation.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
@@ -41,19 +52,57 @@ export class SupportChatAgentController {
|
||||
}
|
||||
|
||||
@Get("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "List messages in a thread" })
|
||||
messages(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.getMessages(id);
|
||||
@ApiOperation({
|
||||
summary: "List messages in a thread (newest page first)",
|
||||
description:
|
||||
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||
"back through history. `nextCursor: null` means the thread's start.",
|
||||
})
|
||||
messages(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getMessages(id, query);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/messages")
|
||||
@ApiOperation({ summary: "Reply as an agent" })
|
||||
@UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
)
|
||||
// Accepts multipart (text + files) or plain JSON (text only) — Multer passes
|
||||
// non-multipart requests straight through, so existing JSON clients are
|
||||
// unaffected.
|
||||
@ApiConsumes("multipart/form-data", "application/json")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
attachments: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: "Reply as an agent, optionally with attachments" })
|
||||
send(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendAsAgent(id, resolveAuthUserId(user), body.body);
|
||||
return this.service.sendAsAgent(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
body.body,
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post("conversations/:id/read")
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
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 {
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SupportAuthorRole,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import {
|
||||
AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from "../../common/resolve-auth-user-id";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from "./attachment-upload.options";
|
||||
import { ListMessagesQueryDto } from "./dto/list-messages-query.dto";
|
||||
import { SendMessageDto } from "./dto/send-message.dto";
|
||||
import { SupportChatService } from "./support-chat.service";
|
||||
|
||||
@@ -29,17 +46,55 @@ export class SupportChatController {
|
||||
}
|
||||
|
||||
@Get("conversation/messages")
|
||||
@ApiOperation({ summary: "Messages in my company's support thread" })
|
||||
messages(@CurrentUser() user: AuthUserPayload) {
|
||||
return this.service.getCustomerMessages(resolveAuthUserId(user));
|
||||
@ApiOperation({
|
||||
summary: "Messages in my company's support thread (newest page first)",
|
||||
description:
|
||||
"Keyset-paginated backwards from the newest message. Omit `before` for " +
|
||||
"the newest page, then pass the previous response's `nextCursor` to walk " +
|
||||
"back through history. `nextCursor: null` means the thread's start.",
|
||||
})
|
||||
messages(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getCustomerMessages(resolveAuthUserId(user), query);
|
||||
}
|
||||
|
||||
@Post("conversation/messages")
|
||||
@ApiOperation({
|
||||
summary: "Send a message as the customer, opening the thread if needed",
|
||||
@UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data", "application/json")
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
body: { type: "string" },
|
||||
attachments: {
|
||||
type: "array",
|
||||
items: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
send(@CurrentUser() user: AuthUserPayload, @Body() body: SendMessageDto) {
|
||||
return this.service.sendAsCustomer(resolveAuthUserId(user), body.body);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Send a message as the customer (optionally with attachments), opening the thread if needed",
|
||||
})
|
||||
send(
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendAsCustomer(
|
||||
resolveAuthUserId(user),
|
||||
body.body,
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post("conversation/read")
|
||||
|
||||
@@ -3,9 +3,11 @@ import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { BackofficeModule } from "../backoffice/backoffice.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module";
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
import { SupportAttachmentController } from "./support-attachment.controller";
|
||||
import { SupportChatAgentController } from "./support-chat-agent.controller";
|
||||
import { SupportChatController } from "./support-chat.controller";
|
||||
import { SupportChatGateway } from "./support-chat.gateway";
|
||||
@@ -23,13 +25,22 @@ import { SupportMessageRepository } from "./support-message.repository";
|
||||
BackofficeModule,
|
||||
// WsAuthService — reused handshake authentication for the gateway.
|
||||
NotificationInboxModule,
|
||||
// FilesService — chat attachments are stored as polymorphic file records.
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [
|
||||
SupportChatController,
|
||||
SupportChatAgentController,
|
||||
SupportAttachmentController,
|
||||
],
|
||||
controllers: [SupportChatController, SupportChatAgentController],
|
||||
providers: [
|
||||
SupportConversationRepository,
|
||||
SupportMessageRepository,
|
||||
SupportChatGateway,
|
||||
SupportChatService,
|
||||
],
|
||||
// FilesController's ownership check for `support_message` files defers to this
|
||||
// service — see SupportAttachmentAccess.
|
||||
exports: [SupportChatService],
|
||||
})
|
||||
export class SupportChatModule {}
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
SendSupportMessageResult,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
SUPPORT_ATTACHMENT_RESOURCE,
|
||||
SupportAttachmentDto,
|
||||
SupportAuthorRole,
|
||||
SupportConversationDto,
|
||||
SupportConversationListResult,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { QueryFailedError } from "typeorm";
|
||||
|
||||
import { BackofficeService } from "../backoffice/backoffice.service";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { ExternalProfileRepository } from "../companies/external-profile.repository";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { ListConversationsQueryDto } from "./dto/list-conversations-query.dto";
|
||||
import {
|
||||
ListMessagesQueryDto,
|
||||
SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
} from "./dto/list-messages-query.dto";
|
||||
import { SupportConversation } from "./entities/support-conversation.entity";
|
||||
import { SupportMessage } from "./entities/support-message.entity";
|
||||
import { decodeMessageCursor, encodeMessageCursor } from "./message-cursor";
|
||||
import { SupportChatGateway } from "./support-chat.gateway";
|
||||
import { SupportConversationRepository } from "./support-conversation.repository";
|
||||
import { SupportMessageRepository } from "./support-message.repository";
|
||||
@@ -30,6 +45,9 @@ interface CustomerContext {
|
||||
/** Postgres unique_violation — the one-thread-per-company index fired. */
|
||||
const PG_UNIQUE_VIOLATION = "23505";
|
||||
|
||||
/** Stand-in preview for a message that is nothing but files. */
|
||||
const ATTACHMENT_ONLY_PREVIEW = "📎";
|
||||
|
||||
@Injectable()
|
||||
export class SupportChatService {
|
||||
constructor(
|
||||
@@ -38,6 +56,8 @@ export class SupportChatService {
|
||||
private readonly gateway: SupportChatGateway,
|
||||
private readonly externalProfiles: ExternalProfileRepository,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly files: FilesService,
|
||||
private readonly backoffice: BackofficeService,
|
||||
) {}
|
||||
|
||||
// ---- customer (portal) -------------------------------------------------
|
||||
@@ -51,7 +71,9 @@ export class SupportChatService {
|
||||
userId: string,
|
||||
): Promise<SupportConversationDto | null> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (!conversation) return null;
|
||||
const unread = await this.messages.unreadCountsByConversation(
|
||||
[conversation.id],
|
||||
@@ -63,17 +85,23 @@ export class SupportChatService {
|
||||
);
|
||||
}
|
||||
|
||||
async getCustomerMessages(userId: string): Promise<SupportMessageDto[]> {
|
||||
async getCustomerMessages(
|
||||
userId: string,
|
||||
query: ListMessagesQueryDto = {},
|
||||
): Promise<SupportMessageListResult> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
if (!conversation) return [];
|
||||
return this.listMessages(conversation.id);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (!conversation) return { items: [], nextCursor: null };
|
||||
return this.listMessages(conversation.id, query);
|
||||
}
|
||||
|
||||
/** Send as the customer, opening the thread if this is the first message. */
|
||||
async sendAsCustomer(
|
||||
userId: string,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<SendSupportMessageResult> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.getOrCreate(
|
||||
@@ -87,16 +115,19 @@ export class SupportChatService {
|
||||
SupportAuthorRole.CUSTOMER,
|
||||
body,
|
||||
ctx.authorName,
|
||||
attachments,
|
||||
);
|
||||
return {
|
||||
conversation: this.toConversationDto(updated, 0),
|
||||
message: this.toMessageDto(message),
|
||||
message: message,
|
||||
};
|
||||
}
|
||||
|
||||
async markCustomerRead(userId: string): Promise<{ unreadCount: number }> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
const conversation = await this.conversations.findByCompanyId(ctx.companyId);
|
||||
const conversation = await this.conversations.findByCompanyId(
|
||||
ctx.companyId,
|
||||
);
|
||||
if (conversation) {
|
||||
await this.conversations.update(conversation.id, {
|
||||
customerLastReadAt: new Date(),
|
||||
@@ -145,7 +176,8 @@ export class SupportChatService {
|
||||
async sendAsAgent(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<SupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
const { message } = await this.appendMessage(
|
||||
@@ -153,8 +185,10 @@ export class SupportChatService {
|
||||
userId,
|
||||
SupportAuthorRole.AGENT,
|
||||
body,
|
||||
undefined,
|
||||
attachments,
|
||||
);
|
||||
return this.toMessageDto(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
async markAgentRead(
|
||||
@@ -171,18 +205,56 @@ export class SupportChatService {
|
||||
// ---- 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.
|
||||
* One page of a thread's messages, newest page first. Pass `asCustomerUserId`
|
||||
* to enforce that the caller's company owns it (portal route); omit for
|
||||
* agents, who see every thread.
|
||||
*/
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
query: ListMessagesQueryDto = {},
|
||||
asCustomerUserId?: string,
|
||||
): Promise<SupportMessageDto[]> {
|
||||
): Promise<SupportMessageListResult> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomerUserId) {
|
||||
await this.assertCustomerOwns(conversation, asCustomerUserId);
|
||||
}
|
||||
return this.listMessages(conversationId);
|
||||
return this.listMessages(conversationId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* May `userId` read the message that a chat attachment hangs off? Backstop for
|
||||
* the file-download route, which otherwise streams any file to any
|
||||
* authenticated caller who knows its UUID.
|
||||
*
|
||||
* Backoffice staff see every thread (they work a shared inbox); a portal user
|
||||
* sees only their own company's. Fails **closed** — an unresolvable message,
|
||||
* conversation, or staff list denies rather than falls through, since the
|
||||
* caller uses this to decide whether to hand over raw bytes.
|
||||
*/
|
||||
async canUserAccessMessage(
|
||||
messageId: string,
|
||||
userId: string,
|
||||
): Promise<boolean> {
|
||||
const message = await this.messages.findById(messageId);
|
||||
if (!message) return false;
|
||||
const conversation = await this.conversations.findById(
|
||||
message.conversationId,
|
||||
);
|
||||
if (!conversation) return false;
|
||||
|
||||
try {
|
||||
const staffIds = await this.backoffice.getAllCurrentEmployeeUserIds();
|
||||
if (staffIds.includes(userId)) return true;
|
||||
} catch {
|
||||
// Staff lookup is best-effort for room-joining in the gateway, but here it
|
||||
// gates bytes: on failure fall through to the (stricter) company check
|
||||
// rather than assuming staff.
|
||||
}
|
||||
|
||||
const profile = await this.externalProfiles.findByUserId(userId);
|
||||
return Boolean(
|
||||
profile?.companyId && profile.companyId === conversation.companyId,
|
||||
);
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
@@ -237,29 +309,86 @@ export class SupportChatService {
|
||||
|
||||
private async listMessages(
|
||||
conversationId: string,
|
||||
): Promise<SupportMessageDto[]> {
|
||||
const rows = await this.messages.listByConversation(conversationId);
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
query: ListMessagesQueryDto,
|
||||
): Promise<SupportMessageListResult> {
|
||||
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||
|
||||
// The repo returns newest-first and over-fetches by one to probe for a
|
||||
// further page.
|
||||
const rows = await this.messages.listByConversation(
|
||||
conversationId,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
const oldest = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && oldest ? encodeMessageCursor(oldest.id) : null;
|
||||
|
||||
// Flip to oldest-first so the client can prepend a page as one block.
|
||||
const items = await this.toMessageDtos([...page].reverse());
|
||||
return { items, nextCursor };
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
/**
|
||||
* Persist a message (plus any attachments), bump the conversation's
|
||||
* denormalized fields, emit live.
|
||||
*
|
||||
* Files are validated *before* the row is written: a rejected upload should
|
||||
* leave no message behind, and a half-uploaded batch is worse than none.
|
||||
*/
|
||||
private async appendMessage(
|
||||
conversation: SupportConversation,
|
||||
userId: string,
|
||||
role: SupportAuthorRole,
|
||||
body: string,
|
||||
body: string | undefined,
|
||||
authorName?: string | null,
|
||||
): Promise<{ conversation: SupportConversation; message: SupportMessage }> {
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<{
|
||||
conversation: SupportConversation;
|
||||
message: SupportMessageDto;
|
||||
}> {
|
||||
const text = (body ?? "").trim();
|
||||
this.assertSendable(text, attachments);
|
||||
|
||||
const message = await this.messages.create({
|
||||
conversationId: conversation.id,
|
||||
authorUserId: userId,
|
||||
authorRole: role,
|
||||
authorName: authorName ?? null,
|
||||
body,
|
||||
// NULL, not "", so "this message has no text" is representable rather than
|
||||
// inferred. The DTO flattens it back to "" for rendering.
|
||||
body: text || null,
|
||||
});
|
||||
|
||||
// The row has to exist before the files, since each one is stored against
|
||||
// `resourceId = message.id`. That leaves a window: if a upload fails here,
|
||||
// the message is already committed. Undo it rather than leave the thread
|
||||
// with a permanently blank bubble — there is no delete flow, so an orphan
|
||||
// would be unremovable, and an attachment-only message that lost its files
|
||||
// has no content at all.
|
||||
let stored: FileRecord[];
|
||||
try {
|
||||
stored = await Promise.all(
|
||||
attachments.map((file) =>
|
||||
this.files.upload({
|
||||
resourceId: message.id,
|
||||
resource: SUPPORT_ATTACHMENT_RESOURCE,
|
||||
code: "attachment",
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
await this.messages.softDelete(message.id);
|
||||
throw error;
|
||||
}
|
||||
|
||||
conversation.lastMessageAt = message.createdAt;
|
||||
conversation.lastMessagePreview = body.slice(0, 280);
|
||||
conversation.lastMessagePreview = this.buildPreview(text, stored);
|
||||
conversation.lastMessageAuthorRole = role;
|
||||
await this.conversations.update(conversation.id, {
|
||||
lastMessageAt: conversation.lastMessageAt,
|
||||
@@ -267,13 +396,55 @@ export class SupportChatService {
|
||||
lastMessageAuthorRole: role,
|
||||
});
|
||||
|
||||
const messageDto = await this.toMessageDto(message, stored);
|
||||
const dto = this.toConversationDto(conversation, 0);
|
||||
this.gateway.emitMessage(
|
||||
conversation.companyId,
|
||||
dto,
|
||||
this.toMessageDto(message),
|
||||
this.gateway.emitMessage(conversation.companyId, dto, messageDto);
|
||||
return { conversation, message: messageDto };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard the chat-specific upload rules. These are tighter than
|
||||
* `FilesService.upload`'s own defence-in-depth checks (25MB, wider MIME set),
|
||||
* which exist for scanned business documents — chat files are pushed at
|
||||
* another human, so the allowlist is narrower and SVG is excluded outright.
|
||||
*/
|
||||
private assertSendable(
|
||||
text: string,
|
||||
attachments: Express.Multer.File[],
|
||||
): void {
|
||||
if (!text && attachments.length === 0) {
|
||||
throw new BadRequestException(
|
||||
"A message needs text or at least one attachment.",
|
||||
);
|
||||
return { conversation, message };
|
||||
}
|
||||
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
throw new BadRequestException(
|
||||
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
}
|
||||
for (const file of attachments) {
|
||||
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported attachment type: ${file.mimetype}`,
|
||||
);
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${file.originalname}" exceeds the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB attachment limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inbox preview line — falls back to the filenames when there's no text. */
|
||||
private buildPreview(text: string, attachments: FileRecord[]): string {
|
||||
if (text) return text.slice(0, 280);
|
||||
if (attachments.length === 1) {
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||
}
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
@@ -320,7 +491,9 @@ export class SupportChatService {
|
||||
): Promise<CustomerContext> {
|
||||
const ctx = await this.resolveCustomer(userId);
|
||||
if (conversation.companyId !== ctx.companyId) {
|
||||
throw new ForbiddenException("This conversation belongs to another company.");
|
||||
throw new ForbiddenException(
|
||||
"This conversation belongs to another company.",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -353,15 +526,58 @@ export class SupportChatService {
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: SupportMessage): SupportMessageDto {
|
||||
/** Hydrate + map a page of messages, batching the attachment lookup. */
|
||||
private async toMessageDtos(
|
||||
rows: SupportMessage[],
|
||||
): Promise<SupportMessageDto[]> {
|
||||
if (rows.length === 0) return [];
|
||||
const grouped = await this.files.findByResourceIdsGrouped(
|
||||
rows.map((r) => r.id),
|
||||
SUPPORT_ATTACHMENT_RESOURCE,
|
||||
);
|
||||
return Promise.all(
|
||||
rows.map((r) => this.toMessageDto(r, grouped.get(r.id) ?? [])),
|
||||
);
|
||||
}
|
||||
|
||||
private async toMessageDto(
|
||||
m: SupportMessage,
|
||||
attachments: FileRecord[],
|
||||
): Promise<SupportMessageDto> {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
authorUserId: m.authorUserId,
|
||||
authorRole: m.authorRole,
|
||||
authorName: m.authorName ?? null,
|
||||
body: m.body,
|
||||
body: m.body ?? "",
|
||||
attachments: attachments.map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: new Date(m.createdAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the browser fetches the bytes: the API's own ownership-checked stream
|
||||
* route, NOT a presigned MinIO URL.
|
||||
*
|
||||
* Presigned object URLs are not reachable from the browser in this deployment
|
||||
* — the same reason every other file in the app streams through
|
||||
* `GET /api/files/:id` rather than a signed URL (see the `fileViewUrl` helper
|
||||
* on the web side, and the minio-js port-443 signature quirk noted there). Chat
|
||||
* attachments stream through `GET /api/support/attachments/:id`, which runs the
|
||||
* same-company / staff ownership check before serving a byte.
|
||||
*
|
||||
* A root-relative path; the web app prepends its API origin. The `<img>` sends
|
||||
* the `auth-token` cookie automatically (same-site across dev ports), which is
|
||||
* how the guard authenticates a request that can't carry a bearer header.
|
||||
*/
|
||||
private toAttachmentDto(f: FileRecord): SupportAttachmentDto {
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
url: `/api/support/attachments/${f.id}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,54 @@ export class SupportMessageRepository extends BaseRepository<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" },
|
||||
});
|
||||
/**
|
||||
* One page of a thread, walking backwards from newest.
|
||||
*
|
||||
* Returns **newest-first** and takes one row more than asked, so the caller
|
||||
* can tell "there is another page" from "this page happened to be full"
|
||||
* without a second COUNT. The caller trims the probe row and flips the page
|
||||
* to oldest-first for rendering.
|
||||
*
|
||||
* Rides IDX_SUPPORT_MSG_CONV_CREATED (conversation_id, created_at); the id in
|
||||
* the keyset is a tiebreak only and doesn't need its own index.
|
||||
*/
|
||||
async listByConversation(
|
||||
conversationId: string,
|
||||
limit: number,
|
||||
beforeId?: string,
|
||||
): Promise<SupportMessage[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("m")
|
||||
.where("m.conversation_id = :conversationId", { conversationId })
|
||||
// createQueryBuilder bypasses TypeORM's soft-delete filter, unlike find().
|
||||
.andWhere("m.deleted_at IS NULL");
|
||||
|
||||
if (beforeId) {
|
||||
// Row-value comparison: strictly older than the cursor row in
|
||||
// (created_at, id) order.
|
||||
//
|
||||
// The cursor's timestamp is read back from the row itself rather than
|
||||
// passed in. `created_at` is timestamptz(6) but a JS Date only holds
|
||||
// milliseconds, so a timestamp that made the round-trip through the API
|
||||
// would arrive truncated — and `.254100 < .254000` is false, so every row
|
||||
// sharing the cursor's millisecond but earlier within it would be skipped
|
||||
// on every page, permanently. Postgres compares the stored values at full
|
||||
// precision instead.
|
||||
qb.andWhere(
|
||||
`(m.created_at, m.id) < (
|
||||
SELECT c.created_at, c.id
|
||||
FROM freight.support_messages c
|
||||
WHERE c.id = :cursorId
|
||||
)`,
|
||||
{ cursorId: beforeId },
|
||||
);
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy("m.created_at", "DESC")
|
||||
.addOrderBy("m.id", "DESC")
|
||||
.take(limit + 1)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
|
||||
import { FileText, X } from "lucide-react";
|
||||
|
||||
import { formatBytes } from "./MessageAttachments";
|
||||
import type { PendingAttachment } from "./useAttachmentDraft";
|
||||
|
||||
/**
|
||||
* The staged-files strip above the composer. Shows what will be sent and lets
|
||||
* the agent drop any of it before hitting send.
|
||||
*/
|
||||
export function AttachmentDraftBar({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs" mb="xs" wrap="wrap">
|
||||
{attachments.map((a) => (
|
||||
<Paper
|
||||
key={a.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
p={4}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" pr={16}>
|
||||
{a.previewUrl ? (
|
||||
<Image
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
w={36}
|
||||
h={36}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
w={36}
|
||||
h={36}
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<FileText size={16} />
|
||||
</Box>
|
||||
)}
|
||||
<Box style={{ minWidth: 0, maxWidth: 120 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.file.name}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{formatBytes(a.file.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
radius="xl"
|
||||
color="gray"
|
||||
variant="filled"
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
onClick={() => onRemove(a.id)}
|
||||
style={{ position: "absolute", top: -6, right: -6 }}
|
||||
>
|
||||
<X size={10} />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
isSupportAttachmentImage,
|
||||
type SupportAttachmentDto,
|
||||
} from "@edr/types";
|
||||
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, ImageOff } from "lucide-react";
|
||||
|
||||
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
|
||||
|
||||
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Cap the bubble: a tall screenshot would push the conversation off-screen. */
|
||||
const THUMB = { maxHeight: 220, maxWidth: 260 } as const;
|
||||
|
||||
/**
|
||||
* One image attachment. Its own component because the bytes are fetched through
|
||||
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
||||
* be called from inside a `.map()`.
|
||||
*/
|
||||
function ImageAttachment({
|
||||
a,
|
||||
onView,
|
||||
}: {
|
||||
a: SupportAttachmentDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Group gap={6} c="dimmed">
|
||||
<ImageOff size={14} />
|
||||
<Text size="xs">Couldn't load {a.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
width: THUMB.maxWidth,
|
||||
height: 140,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onView(a, src)}
|
||||
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
|
||||
>
|
||||
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments inside a message bubble: images as thumbnails, everything else as
|
||||
* a labelled file row. Non-images are not fetched until opened — pulling every
|
||||
* document in a thread just to draw a filename would be wasteful.
|
||||
*/
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
mine,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: SupportAttachmentDto[];
|
||||
mine: boolean;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={6} mt={6}>
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onView} />
|
||||
) : (
|
||||
<Paper
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
px="sm"
|
||||
py={6}
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: mine ? "rgba(255,255,255,0.16)" : "white",
|
||||
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<FileText size={16} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text size="10px" opacity={0.75}>
|
||||
{formatBytes(a.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
SendSupportMessageDto,
|
||||
SupportConversationDto,
|
||||
SupportConversationListResult,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
@@ -14,6 +14,33 @@ export interface ListConversationsParams {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ListMessagesParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
body?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server can persist them against
|
||||
* the message it creates in the same request; text-only stays JSON. Letting
|
||||
* axios set the multipart boundary itself is deliberate — setting
|
||||
* `Content-Type` by hand omits the boundary and the request fails to parse.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
|
||||
if (!input.attachments?.length) return { body: input.body };
|
||||
|
||||
const form = new FormData();
|
||||
if (input.body) form.append("body", input.body);
|
||||
for (const file of input.attachments) form.append("attachments", file);
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice (agent) support-chat REST calls. The backoffice axios `api`
|
||||
* response interceptor already unwraps the `{ success, data }` envelope, so
|
||||
@@ -29,19 +56,39 @@ export const supportApi = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
listMessages: async (id: string): Promise<SupportMessageDto[]> => {
|
||||
const { data } = await api.get<SupportMessageDto[]>(
|
||||
listMessages: async (
|
||||
id: string,
|
||||
params: ListMessagesParams = {},
|
||||
): Promise<SupportMessageListResult> => {
|
||||
const { data } = await api.get<SupportMessageListResult>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
{ params },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through the authenticated client.
|
||||
*
|
||||
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
|
||||
* token from the Authorization header only — there is no cookie fallback — and
|
||||
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
|
||||
* Same reason `filesService.download` exists for booking documents. The caller
|
||||
* wraps this blob in an object URL.
|
||||
*/
|
||||
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
|
||||
// The DTO path is absolute from the API root (`/api/...`), but this client's
|
||||
// baseURL already ends in `/api` — drop the duplicate prefix.
|
||||
const path = relativeUrl.replace(/^\/api/, "");
|
||||
const { data } = await api.get(path, { responseType: "blob" });
|
||||
return data as unknown as Blob;
|
||||
},
|
||||
sendMessage: async (
|
||||
id: string,
|
||||
body: SendSupportMessageDto,
|
||||
input: SendMessageInput,
|
||||
): Promise<SupportMessageDto> => {
|
||||
const { data } = await api.post<SupportMessageDto>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
body,
|
||||
toRequestBody(input),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
isSupportAttachmentAllowed,
|
||||
} from "@edr/types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** A file staged in the composer, not yet sent. */
|
||||
export interface PendingAttachment {
|
||||
/** Local-only id; the server id doesn't exist until the message is sent. */
|
||||
id: string;
|
||||
file: File;
|
||||
/** Object URL, images only. Revoked when the entry goes away. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* Staging area for files being attached to a message.
|
||||
*
|
||||
* Files are held client-side until send, then posted alongside the text in one
|
||||
* multipart request — there's no upload-then-reference step, so nothing to
|
||||
* garbage-collect if the agent changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
|
||||
* every image for the life of the tab.
|
||||
*/
|
||||
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const rejectRef = useRef(onReject);
|
||||
rejectRef.current = onReject;
|
||||
|
||||
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
||||
// still-live URLs) on every change to the list.
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const a of attachmentsRef.current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const add = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setAttachments((current) => {
|
||||
const accepted: PendingAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (
|
||||
current.length + accepted.length >=
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
|
||||
) {
|
||||
rejectRef.current?.(
|
||||
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!isSupportAttachmentAllowed(file.type)) {
|
||||
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
rejectRef.current?.(
|
||||
`${file.name} is over the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB limit.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: `pending-${nextId++}`,
|
||||
file,
|
||||
previewUrl: isSupportAttachmentImage(file.type)
|
||||
? URL.createObjectURL(file)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return accepted.length ? [...current, ...accepted] : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachments((current) => {
|
||||
const target = current.find((a) => a.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return current.filter((a) => a.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachments((current) => {
|
||||
for (const a of current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pull files off a paste. Returns true if anything was taken, so the caller
|
||||
* can suppress the default paste — otherwise pasting a screenshot also drops
|
||||
* its filename (or nothing) into the textarea.
|
||||
*
|
||||
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
|
||||
* clipboard, so check for files first and only then let the text through.
|
||||
*/
|
||||
const addFromPaste = useCallback(
|
||||
(clipboard: DataTransfer | null): boolean => {
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
add(files);
|
||||
return true;
|
||||
},
|
||||
[add],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
files: attachments.map((a) => a.file),
|
||||
add,
|
||||
addFromPaste,
|
||||
remove,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { supportApi } from "./supportApi";
|
||||
|
||||
/**
|
||||
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
|
||||
*
|
||||
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
|
||||
* guard takes the bearer token from the `Authorization` header and has no cookie
|
||||
* fallback, and an `<img>` request cannot carry that header — a direct src is an
|
||||
* unavoidable 401. So the bytes are fetched through the authenticated client and
|
||||
* handed to the browser as an object URL, the same way booking documents are
|
||||
* downloaded.
|
||||
*
|
||||
* The URL is revoked on unmount and whenever the attachment changes, so a thread
|
||||
* scrolled through hundreds of images doesn't pin all of them in memory.
|
||||
*/
|
||||
export function useAttachmentObjectUrl(relativeUrl: string): {
|
||||
src?: string;
|
||||
failed: boolean;
|
||||
} {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let created: string | undefined;
|
||||
|
||||
setSrc(undefined);
|
||||
setFailed(false);
|
||||
|
||||
supportApi
|
||||
.fetchAttachment(relativeUrl)
|
||||
.then((blob) => {
|
||||
// The component may have unmounted mid-flight; creating a URL then would
|
||||
// leak it, since the cleanup below has already run.
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setSrc(created);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [relativeUrl]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand variant for files that aren't previewed inline (documents): fetch
|
||||
* only when the user actually opens one, rather than pulling every attachment in
|
||||
* the thread down just to render a filename row.
|
||||
*
|
||||
* Holds a single slot — opening another file revokes the previous URL, as does
|
||||
* unmounting.
|
||||
*/
|
||||
export function useLazyAttachmentObjectUrl(): (
|
||||
relativeUrl: string,
|
||||
) => Promise<string> {
|
||||
const current = useRef<string>();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useCallback(async (relativeUrl: string) => {
|
||||
const blob = await supportApi.fetchAttachment(relativeUrl);
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
current.current = URL.createObjectURL(blob);
|
||||
return current.current;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { supportApi, type ListConversationsParams } from "./supportApi";
|
||||
import {
|
||||
supportApi,
|
||||
type ListConversationsParams,
|
||||
type SendMessageInput,
|
||||
} from "./supportApi";
|
||||
|
||||
export const SUPPORT_KEY = ["support"] as const;
|
||||
export const SUPPORT_CONVERSATIONS_KEY = ["support", "conversations"] as const;
|
||||
@@ -8,20 +25,145 @@ 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. */
|
||||
/** Threads per page in the inbox. */
|
||||
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||
/** Messages per page in a thread. */
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Shared inbox: every thread, filterable by unread + company-name search.
|
||||
*
|
||||
* Pages on scroll. This previously asked for `limit: 100` and rendered whatever
|
||||
* came back — which silently truncated the inbox at the server's own max of 100
|
||||
* with no way to reach the rest.
|
||||
*/
|
||||
export function useConversations(params: ListConversationsParams = {}) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
||||
queryFn: () => supportApi.listConversations({ limit: 100, ...params }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listConversations({
|
||||
...params,
|
||||
page: pageParam,
|
||||
limit: CONVERSATIONS_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const loaded = allPages.reduce((n, page) => n + page.items.length, 0);
|
||||
return loaded < lastPage.count ? allPages.length + 1 : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Flatten for rendering, keeping the page-level fields (count/unreadCount)
|
||||
* from the newest fetch so badges don't go stale as more pages load.
|
||||
*
|
||||
* De-duplicated by id because this list pages by OFFSET over a sort key that
|
||||
* moves: a thread jumps to rank 1 the moment it gets a message, shifting
|
||||
* everything down, so a row already shown on page 1 can be served again on
|
||||
* page 2 — and React would then see two children with the same key. The
|
||||
* conversations invalidate that rides along with every such event heals the
|
||||
* ordering a beat later; this just stops the intervening render from breaking.
|
||||
*
|
||||
* Keyset wouldn't help here, unlike the message list: the sort key itself
|
||||
* mutates, so no cursor over it is stable either.
|
||||
*/
|
||||
const items = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const flat: SupportConversationDto[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const conversation of page.items) {
|
||||
if (seen.has(conversation.id)) continue;
|
||||
seen.add(conversation.id);
|
||||
flat.push(conversation);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
items,
|
||||
count: query.data?.pages[0]?.count ?? 0,
|
||||
unreadCount: query.data?.pages[0]?.unreadCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's messages, paged backwards from newest.
|
||||
*
|
||||
* react-query's "next page" is *older* history here, so `pages` runs
|
||||
* newest-block-first and has to be reversed to render top-to-bottom in time
|
||||
* order. Cursor-based rather than offset so a message arriving mid-scroll
|
||||
* doesn't shift the pages already loaded.
|
||||
*/
|
||||
export function useMessages(conversationId: string | null) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: supportMessagesKey(conversationId ?? ""),
|
||||
queryFn: () => supportApi.listMessages(conversationId as string),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listMessages(conversationId as string, {
|
||||
before: pageParam,
|
||||
limit: MESSAGES_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// Exposed so the view can tell a prepended history page from a message
|
||||
// appended at the bottom — `messages` changing says nothing about which.
|
||||
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into a cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: that refetches *every* page the agent
|
||||
* has scrolled back through, so the cost of each inbound message would grow with
|
||||
* how far they've read. Page 0 is the newest block and its items are oldest-first
|
||||
* within the block, so the new message belongs on its end.
|
||||
*
|
||||
* No-ops when the thread isn't cached — nothing is rendering it, and seeding a
|
||||
* partial cache here would leave a thread whose "first page" is one message and
|
||||
* whose `nextCursor` is missing.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than a boolean: the two ways it can decline
|
||||
* to append need opposite handling. A duplicate is the sender's own echo and must
|
||||
* be ignored — refetching there would undo the whole point. "Uncached" means the
|
||||
* thread's first page is still in flight and may have been read on the server
|
||||
* *before* this message existed, so dropping it silently would lose it until
|
||||
* something else happened to refetch; the caller refetches instead. That's cheap
|
||||
* precisely because nothing is loaded yet.
|
||||
*/
|
||||
export type AppendOutcome = "appended" | "duplicate" | "uncached";
|
||||
|
||||
export function appendMessageToCache(
|
||||
qc: QueryClient,
|
||||
message: SupportMessageDto,
|
||||
): AppendOutcome {
|
||||
let outcome: AppendOutcome = "uncached";
|
||||
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
|
||||
supportMessagesKey(message.conversationId),
|
||||
(current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
if (newest.items.some((m) => m.id === message.id)) {
|
||||
outcome = "duplicate";
|
||||
return current;
|
||||
}
|
||||
outcome = "appended";
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
|
||||
};
|
||||
},
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function useSupportUnreadCount(enabled = true) {
|
||||
@@ -36,10 +178,13 @@ export function useSupportUnreadCount(enabled = true) {
|
||||
export function useSendMessage(conversationId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: string) =>
|
||||
supportApi.sendMessage(conversationId, { body }),
|
||||
mutationFn: (input: SendMessageInput) =>
|
||||
supportApi.sendMessage(conversationId, input),
|
||||
// The gateway echoes our own message back over the socket, which appends it
|
||||
// to the cache — so don't invalidate the thread here or every send would
|
||||
// refetch every page the agent has scrolled through. The conversation list
|
||||
// still needs a refresh for its last-message preview and ordering.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import {
|
||||
appendMessageToCache,
|
||||
SUPPORT_CONVERSATIONS_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
supportMessagesKey,
|
||||
@@ -45,14 +46,25 @@ export function useSupportSocket(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||
socket.on(
|
||||
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: SupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated, and invalidating
|
||||
// it would refetch every page the agent has scrolled back through on every
|
||||
// single inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === "uncached") {
|
||||
// The thread's first page is still loading and may have been read before
|
||||
// this message existed — without this it would go missing until some
|
||||
// unrelated refetch. Cheap: there are no pages to re-fetch yet.
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_ACCEPT,
|
||||
SupportAuthorRole,
|
||||
type SupportAttachmentDto,
|
||||
type SupportConversationDto,
|
||||
type SupportMessageDto,
|
||||
} from "@edr/types";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -23,10 +26,22 @@ import {
|
||||
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 {
|
||||
Building2,
|
||||
Headset,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLazyAttachmentObjectUrl } from "@/features/support/useAttachmentObjectUrl";
|
||||
import { AttachmentDraftBar } from "@/features/support/AttachmentDraftBar";
|
||||
import { MessageAttachments } from "@/features/support/MessageAttachments";
|
||||
import { useAttachmentDraft } from "@/features/support/useAttachmentDraft";
|
||||
import {
|
||||
useConversations,
|
||||
useMarkConversationRead,
|
||||
@@ -39,6 +54,9 @@ import { customersService } from "@/services/customers.service";
|
||||
|
||||
type ReadFilter = "ALL" | "UNREAD";
|
||||
|
||||
/** Distance from an edge (px) that counts as "at" it. */
|
||||
const SCROLL_EDGE_SLOP = 120;
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
@@ -54,12 +72,24 @@ export default function SupportInboxPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const inboxViewport = useRef<HTMLDivElement>(null);
|
||||
/**
|
||||
* The thread just opened from the company picker.
|
||||
*
|
||||
* Selection resolves against the *loaded* pages, and a company picked from the
|
||||
* modal may well have a thread that sits far enough down the list to not be
|
||||
* loaded yet — in which case the lookup below would find nothing and the pane
|
||||
* would sit blank. Hold onto the conversation the server handed back so the
|
||||
* pane can open immediately, regardless of where it falls in the inbox.
|
||||
*/
|
||||
const [startedConversation, setStartedConversation] =
|
||||
useState<SupportConversationDto | null>(null);
|
||||
|
||||
const { data, isLoading } = useConversations({
|
||||
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useConversations({
|
||||
search,
|
||||
unreadOnly: readFilter === "UNREAD",
|
||||
});
|
||||
const items = data?.items ?? [];
|
||||
|
||||
useSupportSocket(true, (event) => {
|
||||
if (event.message.authorRole === SupportAuthorRole.CUSTOMER) {
|
||||
@@ -70,10 +100,13 @@ export default function SupportInboxPage() {
|
||||
}
|
||||
});
|
||||
|
||||
const selected = useMemo(
|
||||
() => items.find((c) => c.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
// Prefer the live row from the list (its unread count and last message stay
|
||||
// current); fall back to the picker's copy while its page is still unloaded.
|
||||
const selected = useMemo(() => {
|
||||
const fromList = items.find((c) => c.id === selectedId);
|
||||
if (fromList) return fromList;
|
||||
return startedConversation?.id === selectedId ? startedConversation : null;
|
||||
}, [items, selectedId, startedConversation]);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
@@ -109,7 +142,10 @@ export default function SupportInboxPage() {
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Box p="sm" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
@@ -140,24 +176,47 @@ export default function SupportInboxPage() {
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<ScrollArea style={{ flex: 1 }} type="hover">
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
type="hover"
|
||||
// Pull the next page in as the agent nears the end of the list.
|
||||
// Previously the hook asked for 100 rows and stopped there, so any
|
||||
// company past the hundredth was simply unreachable.
|
||||
onScrollPositionChange={({ y }) => {
|
||||
const el = inboxViewport.current;
|
||||
if (!el || !hasNextPage || isFetchingNextPage) return;
|
||||
if (el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
viewportRef={inboxViewport}
|
||||
>
|
||||
{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."}
|
||||
{readFilter === "UNREAD"
|
||||
? "Nothing unread."
|
||||
: "No conversations."}
|
||||
</Text>
|
||||
) : (
|
||||
items.map((c) => (
|
||||
<>
|
||||
{items.map((c) => (
|
||||
<InboxRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
active={c.id === selectedId}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<Group justify="center" p="sm">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
@@ -168,7 +227,12 @@ export default function SupportInboxPage() {
|
||||
<ConversationThread conversation={selected} />
|
||||
) : (
|
||||
<Stack align="center" justify="center" h="100%" c="dimmed" gap="xs">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="xl" size={56}>
|
||||
<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>
|
||||
@@ -180,8 +244,9 @@ export default function SupportInboxPage() {
|
||||
<CompanyPicker
|
||||
opened={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStarted={(id) => {
|
||||
setSelectedId(id);
|
||||
onStarted={(conversation) => {
|
||||
setStartedConversation(conversation);
|
||||
setSelectedId(conversation.id);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
@@ -200,7 +265,7 @@ function CompanyPicker({
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onStarted: (conversationId: string) => void;
|
||||
onStarted: (conversation: SupportConversationDto) => void;
|
||||
}) {
|
||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||
const start = useStartConversation();
|
||||
@@ -224,15 +289,23 @@ function CompanyPicker({
|
||||
if (!companyId) return;
|
||||
const conversation = await start.mutateAsync(companyId);
|
||||
setCompanyId(null);
|
||||
onStarted(conversation.id);
|
||||
onStarted(conversation);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Start a chat" radius="md" centered>
|
||||
<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"}
|
||||
placeholder={
|
||||
isLoading ? "Loading companies…" : "Search for a company"
|
||||
}
|
||||
data={options}
|
||||
value={companyId}
|
||||
onChange={setCompanyId}
|
||||
@@ -319,26 +392,133 @@ function ConversationThread({
|
||||
}: {
|
||||
conversation: SupportConversationDto;
|
||||
}) {
|
||||
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||
const {
|
||||
messages,
|
||||
pageCount,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useMessages(conversation.id);
|
||||
const send = useSendMessage(conversation.id);
|
||||
const markRead = useMarkConversationRead();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
const attach = useAttachmentDraft((reason) => toast.error(reason));
|
||||
|
||||
/**
|
||||
* Scroll height captured just before an older page was requested, tagged with
|
||||
* the page count at that moment.
|
||||
*
|
||||
* The page count is what makes this safe. Keyed on presence alone, a message
|
||||
* arriving over the socket while history was still in flight would consume the
|
||||
* snapshot on a one-bubble append, and the real 30-message prepend would then
|
||||
* land with nothing to correct against — throwing the reader exactly as far as
|
||||
* this exists to prevent. Comparing counts means only an actual new page can
|
||||
* claim it.
|
||||
*/
|
||||
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
|
||||
null,
|
||||
);
|
||||
/** Whether the agent is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
/** Which thread the refs above describe; a switch resets them. */
|
||||
const anchoredThread = useRef(conversation.id);
|
||||
|
||||
const messageCount = messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
markRead.mutate(conversation.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation.id, messages?.length]);
|
||||
}, [conversation.id, messageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length, conversation.id]);
|
||||
/**
|
||||
* Keep the viewport sensible as the list changes underneath it.
|
||||
*
|
||||
* Two different things change `messages`, and they want opposite behaviour: a
|
||||
* new message at the bottom should follow (if the agent is already there),
|
||||
* while an older page prepended at the top must NOT move what they're reading.
|
||||
* Layout effect, not effect — this must run before paint or the prepend
|
||||
* visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
|
||||
// Thread switch: start a fresh read at the bottom and drop the previous
|
||||
// thread's anchoring state.
|
||||
if (anchoredThread.current !== conversation.id) {
|
||||
anchoredThread.current = conversation.id;
|
||||
pendingRestore.current = null;
|
||||
stick.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = pendingRestore.current;
|
||||
if (restore && pageCount > restore.atPageCount) {
|
||||
// An older page went in above: push the scroll down by exactly the height
|
||||
// that was added, so the same message stays under the cursor.
|
||||
el.scrollTop += el.scrollHeight - restore.height;
|
||||
pendingRestore.current = null;
|
||||
return;
|
||||
}
|
||||
if (stick.current) el.scrollTo({ top: el.scrollHeight });
|
||||
}, [messages, pageCount, conversation.id]);
|
||||
|
||||
const onScroll = ({ y }: { y: number }) => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
|
||||
// A failed fetch leaves this set, which is harmless: the list didn't
|
||||
// change, so the height is still accurate for the retry, and the count
|
||||
// tag stops it being mistaken for a landed page in the meantime.
|
||||
pendingRestore.current = {
|
||||
height: el.scrollHeight,
|
||||
atPageCount: pageCount,
|
||||
};
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
if (!body && attach.attachments.length === 0) return;
|
||||
const files = attach.files;
|
||||
// Clear optimistically so the composer feels instant; on failure the text is
|
||||
// restored below rather than silently lost.
|
||||
setDraft("");
|
||||
await send.mutateAsync(body);
|
||||
attach.clear();
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ body: body || undefined, attachments: files });
|
||||
} catch (error) {
|
||||
setDraft(body);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Couldn't send that message.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const openAttachment = (a: SupportAttachmentDto, src: string) =>
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
|
||||
// Documents aren't fetched until opened.
|
||||
const openFile = async (a: SupportAttachmentDto) => {
|
||||
try {
|
||||
const src = await loadAttachment(a.url);
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
} catch {
|
||||
toast.error(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -361,36 +541,108 @@ function ConversationThread({
|
||||
</Group>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
viewportRef={viewport}
|
||||
type="hover"
|
||||
onScrollPositionChange={onScroll}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Group>
|
||||
) : (messages ?? []).length === 0 ? (
|
||||
) : 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} />
|
||||
{isFetchingNextPage && (
|
||||
<Group justify="center" py="xs">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Group>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
Start of conversation
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<AgentBubble
|
||||
key={m.id}
|
||||
m={m}
|
||||
onView={openAttachment}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Composer */}
|
||||
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||
outline: dragging
|
||||
? "2px dashed var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
outlineOffset: -4,
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<AttachmentDraftBar
|
||||
attachments={attach.attachments}
|
||||
onRemove={attach.remove}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
attach.add(Array.from(e.currentTarget.files ?? []));
|
||||
// Reset so picking the same file twice in a row still fires change.
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Attach files"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</ActionIcon>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder="Type your message… (Enter to send, Shift+Enter for newline)"
|
||||
placeholder="Type a message, or paste an image… (Enter to send, Shift+Enter for newline)"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={5}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
// Screenshots land on the clipboard as files. Take them and suppress
|
||||
// the default, which would otherwise also paste the image's name (or
|
||||
// nothing) as text.
|
||||
onPaste={(e) => {
|
||||
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -404,18 +656,27 @@ function ConversationThread({
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
loading={send.isPending}
|
||||
disabled={!draft.trim()}
|
||||
disabled={!draft.trim() && attach.attachments.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
function AgentBubble({
|
||||
m,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: SupportMessageDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.authorRole === SupportAuthorRole.AGENT;
|
||||
return (
|
||||
<Group
|
||||
@@ -430,7 +691,13 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
</Avatar>
|
||||
)}
|
||||
<Box style={{ maxWidth: "70%" }}>
|
||||
<Text size="xs" c="dimmed" mb={2} ml={mine ? 0 : 4} ta={mine ? "right" : "left"}>
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
mb={2}
|
||||
ml={mine ? 0 : 4}
|
||||
ta={mine ? "right" : "left"}
|
||||
>
|
||||
{mine ? m.authorName || "You" : m.authorName || "Customer"}
|
||||
</Text>
|
||||
<Paper
|
||||
@@ -446,9 +713,20 @@ function AgentBubble({ m }: { m: SupportMessageDto }) {
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body && (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{m.body}
|
||||
</Text>
|
||||
)}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onView={onView}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Paper>
|
||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||
{formatTime(m.createdAt)}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ActionIcon, Box, Group, Image, Paper, Text } from "@mantine/core";
|
||||
import { FileText, X } from "lucide-react";
|
||||
|
||||
import { formatBytes } from "./MessageAttachments";
|
||||
import type { PendingAttachment } from "./useAttachmentDraft";
|
||||
|
||||
/**
|
||||
* The staged-files strip above the composer. Shows what will be sent and lets
|
||||
* the customer drop any of it before hitting send.
|
||||
*/
|
||||
export function AttachmentDraftBar({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs" mb="xs" wrap="wrap">
|
||||
{attachments.map((a) => (
|
||||
<Paper
|
||||
key={a.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
p={4}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Group gap={6} wrap="nowrap" pr={16}>
|
||||
{a.previewUrl ? (
|
||||
<Image
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
w={32}
|
||||
h={32}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
w={32}
|
||||
h={32}
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<FileText size={14} />
|
||||
</Box>
|
||||
)}
|
||||
{/* Narrower name cap than the backoffice so two chips still fit
|
||||
across the widget's 384px column. */}
|
||||
<Box style={{ minWidth: 0, maxWidth: 92 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.file.name}
|
||||
</Text>
|
||||
<Text size="10px" c="dimmed">
|
||||
{formatBytes(a.file.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
radius="xl"
|
||||
color="gray"
|
||||
variant="filled"
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
onClick={() => onRemove(a.id)}
|
||||
style={{ position: "absolute", top: -6, right: -6 }}
|
||||
>
|
||||
<X size={10} />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
isSupportAttachmentImage,
|
||||
type SupportAttachmentDto,
|
||||
} from "@edr/types";
|
||||
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { FileText, ImageOff } from "lucide-react";
|
||||
|
||||
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
|
||||
|
||||
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Thumbnail box size — the widget is a narrow column, so keep it modest. */
|
||||
const THUMB = { maxHeight: 180, maxWidth: 200 } as const;
|
||||
|
||||
/**
|
||||
* One image attachment. Its own component because the bytes are fetched through
|
||||
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
||||
* be called from inside a `.map()`.
|
||||
*/
|
||||
function ImageAttachment({
|
||||
a,
|
||||
onView,
|
||||
}: {
|
||||
a: SupportAttachmentDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Group gap={6} c="dimmed">
|
||||
<ImageOff size={14} />
|
||||
<Text size="xs">Couldn't load {a.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
...THUMB,
|
||||
width: THUMB.maxWidth,
|
||||
height: 120,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => onView(a, src)}
|
||||
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
|
||||
>
|
||||
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments inside a message bubble: images as thumbnails, everything else as
|
||||
* a labelled file row. Non-images are not fetched until opened — pulling every
|
||||
* document in a thread just to draw a filename would be wasteful.
|
||||
*/
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
mine,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: SupportAttachmentDto[];
|
||||
mine: boolean;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack gap={6} mt={6}>
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onView} />
|
||||
) : (
|
||||
<Paper
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
px="sm"
|
||||
py={6}
|
||||
radius="md"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
background: mine ? "rgba(255,255,255,0.16)" : "white",
|
||||
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<FileText size={16} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={600} truncate>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text size="10px" opacity={0.75}>
|
||||
{formatBytes(a.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_ACCEPT,
|
||||
SupportAuthorRole,
|
||||
type SupportAttachmentDto,
|
||||
type SupportMessageDto,
|
||||
} from "@edr/types";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
@@ -12,9 +18,14 @@ import {
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Headset, Send, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Headset, Paperclip, Send, X } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { AttachmentDraftBar } from "./AttachmentDraftBar";
|
||||
import { MessageAttachments } from "./MessageAttachments";
|
||||
import { useAttachmentDraft } from "./useAttachmentDraft";
|
||||
import { useLazyAttachmentObjectUrl } from "./useAttachmentObjectUrl";
|
||||
import {
|
||||
useConversation,
|
||||
useMarkConversationRead,
|
||||
@@ -22,6 +33,9 @@ import {
|
||||
useSendMessage,
|
||||
} from "./useSupport";
|
||||
|
||||
/** Distance from an edge (px) that counts as "at" it. */
|
||||
const SCROLL_EDGE_SLOP = 120;
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
@@ -43,32 +57,136 @@ export interface SupportPanelProps {
|
||||
*/
|
||||
export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||
const { data: conversation } = useConversation();
|
||||
const { data: messages, isLoading } = useMessages();
|
||||
const {
|
||||
messages,
|
||||
pageCount,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useMessages();
|
||||
const send = useSendMessage();
|
||||
const markRead = useMarkConversationRead();
|
||||
const [draft, setDraft] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const { view, viewer } = useFileViewer();
|
||||
const attach = useAttachmentDraft((reason) => toast.error(reason));
|
||||
|
||||
/**
|
||||
* Scroll height captured just before an older page was requested, tagged with
|
||||
* the page count at that moment.
|
||||
*
|
||||
* The page count is what makes this safe. Keyed on presence alone, a message
|
||||
* arriving over the socket while history was still in flight would consume the
|
||||
* snapshot on a one-bubble append, and the real 30-message prepend would then
|
||||
* land with nothing to correct against — throwing the reader exactly as far as
|
||||
* this exists to prevent. Comparing counts means only an actual new page can
|
||||
* claim it.
|
||||
*/
|
||||
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(
|
||||
null,
|
||||
);
|
||||
/** Whether the customer is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
/** Which thread the refs above describe; a switch resets them. */
|
||||
const anchoredThread = useRef(conversation?.id);
|
||||
|
||||
// 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]);
|
||||
}, [conversation?.id, messages.length]);
|
||||
|
||||
// Auto-scroll to newest.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length]);
|
||||
/**
|
||||
* Keep the viewport sensible as the list changes underneath it.
|
||||
*
|
||||
* Two different things change `messages`, and they want opposite behaviour:
|
||||
* a new message at the bottom should follow (if the customer is already
|
||||
* there), while an older page prepended at the top must NOT move the content
|
||||
* they're reading. Layout effect, not effect — this has to run before paint or
|
||||
* the prepend visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
|
||||
// A different thread underneath us (the first send creates one): start a
|
||||
// fresh read at the bottom and drop the previous thread's anchoring state.
|
||||
if (anchoredThread.current !== conversation?.id) {
|
||||
anchoredThread.current = conversation?.id;
|
||||
pendingRestore.current = null;
|
||||
stick.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = pendingRestore.current;
|
||||
if (restore && pageCount > restore.atPageCount) {
|
||||
// An older page went in above: push the scroll down by exactly the height
|
||||
// that was added, so the same message stays under the cursor.
|
||||
el.scrollTop += el.scrollHeight - restore.height;
|
||||
pendingRestore.current = null;
|
||||
return;
|
||||
}
|
||||
if (stick.current) el.scrollTo({ top: el.scrollHeight });
|
||||
}, [messages, pageCount, conversation?.id]);
|
||||
|
||||
const onScroll = ({ y }: { y: number }) => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
|
||||
// A failed fetch leaves this set, which is harmless: the list didn't
|
||||
// change, so the height is still accurate for the retry, and the count
|
||||
// tag stops it being mistaken for a landed page in the meantime.
|
||||
pendingRestore.current = {
|
||||
height: el.scrollHeight,
|
||||
atPageCount: pageCount,
|
||||
};
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
if (!body && attach.attachments.length === 0) return;
|
||||
const files = attach.files;
|
||||
// Clear optimistically so the composer feels instant; on failure the text is
|
||||
// restored below rather than silently lost.
|
||||
setDraft("");
|
||||
await send.mutateAsync(body);
|
||||
attach.clear();
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ body: body || undefined, attachments: files });
|
||||
} catch (error) {
|
||||
setDraft(body);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Couldn't send that message.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && (messages ?? []).length === 0;
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const openAttachment = (a: SupportAttachmentDto, src: string) =>
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
|
||||
// Documents aren't fetched until opened.
|
||||
const openFile = async (a: SupportAttachmentDto) => {
|
||||
try {
|
||||
const src = await loadAttachment(a.url);
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
} catch {
|
||||
toast.error(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = !isLoading && messages.length === 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -113,7 +231,12 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} type="hover">
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
viewportRef={viewport}
|
||||
type="hover"
|
||||
onScrollPositionChange={onScroll}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
@@ -129,24 +252,91 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap="sm" p="md">
|
||||
{(messages ?? []).map((m) => (
|
||||
<MessageBubble key={m.id} m={m} />
|
||||
{isFetchingNextPage && (
|
||||
<Group justify="center" py="xs">
|
||||
<Loader size="xs" color="edr-green" />
|
||||
</Group>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<Text size="10px" c="dimmed" ta="center">
|
||||
Start of conversation
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<MessageBubble
|
||||
key={m.id}
|
||||
m={m}
|
||||
onView={openAttachment}
|
||||
onOpenFile={openFile}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<Box p="sm" style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||
background: dragging ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||
outline: dragging
|
||||
? "2px dashed var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
outlineOffset: -4,
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
<AttachmentDraftBar
|
||||
attachments={attach.attachments}
|
||||
onRemove={attach.remove}
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
attach.add(Array.from(e.currentTarget.files ?? []));
|
||||
// Reset so picking the same file twice in a row still fires change.
|
||||
e.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
size={38}
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label="Attach files"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</ActionIcon>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
placeholder="Type a message…"
|
||||
placeholder="Type a message, or paste an image…"
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
// Screenshots land on the clipboard as files. Take them and suppress
|
||||
// the default, which would otherwise also paste the image's name (or
|
||||
// nothing) as text.
|
||||
onPaste={(e) => {
|
||||
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -160,18 +350,27 @@ export function SupportPanel({ onClose }: SupportPanelProps) {
|
||||
color="edr-green"
|
||||
variant="filled"
|
||||
loading={send.isPending}
|
||||
disabled={!draft.trim()}
|
||||
disabled={!draft.trim() && attach.attachments.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
{viewer}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: SupportMessageDto }) {
|
||||
function MessageBubble({
|
||||
m,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: SupportMessageDto;
|
||||
onView: (a: SupportAttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: SupportAttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.authorRole === SupportAuthorRole.CUSTOMER;
|
||||
return (
|
||||
<Group
|
||||
@@ -204,9 +403,20 @@ function MessageBubble({ m }: { m: SupportMessageDto }) {
|
||||
borderBottomLeftRadius: mine ? undefined : 4,
|
||||
}}
|
||||
>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{m.body && (
|
||||
<Text
|
||||
size="sm"
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{m.body}
|
||||
</Text>
|
||||
)}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onView={onView}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Paper>
|
||||
<Text size="10px" c="dimmed" mt={2} ta={mine ? "right" : "left"}>
|
||||
{formatTime(m.createdAt)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SupportAuthorRole } from "@edr/types";
|
||||
import { SupportAuthorRole, type SupportMessageDto } from "@edr/types";
|
||||
import { Affix, Indicator, Transition } from "@mantine/core";
|
||||
import { Headset } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -10,6 +10,17 @@ import { SupportPanel } from "./SupportPanel";
|
||||
import { useSupportUnreadCount } from "./useSupport";
|
||||
import { useSupportSocket } from "./useSupportSocket";
|
||||
|
||||
/**
|
||||
* Toast blurb for an inbound reply. A message may carry files and no text at
|
||||
* all, which would otherwise toast a bare "Support replied:".
|
||||
*/
|
||||
function previewOf(message: SupportMessageDto): string {
|
||||
const body = message.body.trim();
|
||||
if (body) return body.length > 60 ? `${body.slice(0, 60)}…` : body;
|
||||
const count = message.attachments.length;
|
||||
return count === 1 ? "sent a file" : `sent ${count} files`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating customer-support launcher, mounted in the authenticated app shell.
|
||||
* Shows an unread badge and opens the chat panel; live pushes keep the badge
|
||||
@@ -23,12 +34,7 @@ export function SupportWidget() {
|
||||
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: "💬" },
|
||||
);
|
||||
toast(`Support replied: ${previewOf(event.message)}`, { icon: "💬" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import type {
|
||||
SendSupportMessageResult,
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
export interface ListMessagesParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
body?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server can persist them against
|
||||
* the message it creates in the same request; text-only stays JSON. Letting
|
||||
* axios set the multipart boundary itself is deliberate — setting
|
||||
* `Content-Type` by hand omits the boundary and the request fails to parse.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): FormData | { body?: string } {
|
||||
if (!input.attachments?.length) return { body: input.body };
|
||||
|
||||
const form = new FormData();
|
||||
if (input.body) form.append("body", input.body);
|
||||
for (const file of input.attachments) form.append("attachments", file);
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -20,14 +47,37 @@ export const supportApi = {
|
||||
const { data } = await client.get("/api/support/conversation");
|
||||
return data.data;
|
||||
},
|
||||
listMessages: async (): Promise<SupportMessageDto[]> => {
|
||||
const { data } = await client.get("/api/support/conversation/messages");
|
||||
listMessages: async (
|
||||
params: ListMessagesParams = {},
|
||||
): Promise<SupportMessageListResult> => {
|
||||
const { data } = await client.get("/api/support/conversation/messages", {
|
||||
params,
|
||||
});
|
||||
return data.data;
|
||||
},
|
||||
sendMessage: async (body: string): Promise<SendSupportMessageResult> => {
|
||||
const { data } = await client.post("/api/support/conversation/messages", {
|
||||
body,
|
||||
});
|
||||
/**
|
||||
* Attachment bytes, fetched through the authenticated client.
|
||||
*
|
||||
* Deliberately not a direct `<img src={url}>`: the API guard reads the bearer
|
||||
* token from the Authorization header only — there is no cookie fallback — and
|
||||
* an `<img>` request cannot carry one, so a direct src is an unavoidable 401.
|
||||
* The same reason `filesService.download` exists for booking documents. The
|
||||
* caller wraps this blob in an object URL.
|
||||
*
|
||||
* `relativeUrl` is the DTO's `url` (`/api/support/attachments/:id`), so the
|
||||
* route stays owned by the server.
|
||||
*/
|
||||
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
|
||||
const { data } = await client.get(relativeUrl, { responseType: "blob" });
|
||||
return data as Blob;
|
||||
},
|
||||
sendMessage: async (
|
||||
input: SendMessageInput,
|
||||
): Promise<SendSupportMessageResult> => {
|
||||
const { data } = await client.post(
|
||||
"/api/support/conversation/messages",
|
||||
toRequestBody(input),
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
markRead: async (): Promise<{ unreadCount: number }> => {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from "@edr/types";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** A file staged in the composer, not yet sent. */
|
||||
export interface PendingAttachment {
|
||||
/** Local-only id; the server id doesn't exist until the message is sent. */
|
||||
id: string;
|
||||
file: File;
|
||||
/** Object URL, images only. Revoked when the entry goes away. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* Staging area for files being attached to a message.
|
||||
*
|
||||
* Files are held client-side until send, then posted alongside the text in one
|
||||
* multipart request — there's no upload-then-reference step, so nothing to
|
||||
* garbage-collect if the customer changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, the widget — which lives for the whole session in the app shell — would
|
||||
* hold the full bytes of every image ever staged for the life of the tab.
|
||||
*/
|
||||
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const rejectRef = useRef(onReject);
|
||||
rejectRef.current = onReject;
|
||||
|
||||
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
||||
// still-live URLs) on every change to the list.
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const a of attachmentsRef.current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const add = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setAttachments((current) => {
|
||||
const accepted: PendingAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (
|
||||
current.length + accepted.length >=
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE
|
||||
) {
|
||||
rejectRef.current?.(
|
||||
`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!isSupportAttachmentAllowed(file.type)) {
|
||||
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
rejectRef.current?.(
|
||||
`${file.name} is over the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB limit.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: `pending-${nextId++}`,
|
||||
file,
|
||||
previewUrl: isSupportAttachmentImage(file.type)
|
||||
? URL.createObjectURL(file)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return accepted.length ? [...current, ...accepted] : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachments((current) => {
|
||||
const target = current.find((a) => a.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return current.filter((a) => a.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachments((current) => {
|
||||
for (const a of current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pull files off a paste. Returns true if anything was taken, so the caller
|
||||
* can suppress the default paste — otherwise pasting a screenshot also drops
|
||||
* its filename (or nothing) into the textarea.
|
||||
*
|
||||
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
|
||||
* clipboard, so check for files first and only then let the text through.
|
||||
*/
|
||||
const addFromPaste = useCallback(
|
||||
(clipboard: DataTransfer | null): boolean => {
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
add(files);
|
||||
return true;
|
||||
},
|
||||
[add],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
files: attachments.map((a) => a.file),
|
||||
add,
|
||||
addFromPaste,
|
||||
remove,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { supportApi } from "./supportApi";
|
||||
|
||||
/**
|
||||
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
|
||||
*
|
||||
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
|
||||
* guard takes the bearer token from the `Authorization` header and has no cookie
|
||||
* fallback, and an `<img>` request cannot carry that header — a direct src is an
|
||||
* unavoidable 401. So the bytes are fetched through the authenticated client and
|
||||
* handed to the browser as an object URL, the same way booking documents are
|
||||
* downloaded.
|
||||
*
|
||||
* The URL is revoked on unmount and whenever the attachment changes, so a thread
|
||||
* scrolled through hundreds of images doesn't pin all of them in memory.
|
||||
*/
|
||||
export function useAttachmentObjectUrl(relativeUrl: string): {
|
||||
src?: string;
|
||||
failed: boolean;
|
||||
} {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let created: string | undefined;
|
||||
|
||||
setSrc(undefined);
|
||||
setFailed(false);
|
||||
|
||||
supportApi
|
||||
.fetchAttachment(relativeUrl)
|
||||
.then((blob) => {
|
||||
// The component may have unmounted mid-flight; creating a URL then would
|
||||
// leak it, since the cleanup below has already run.
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setSrc(created);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [relativeUrl]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand variant for files that aren't previewed inline (documents): fetch
|
||||
* only when the user actually opens one, rather than pulling every attachment in
|
||||
* the thread down just to render a filename row.
|
||||
*
|
||||
* Holds a single slot — opening another file revokes the previous URL, as does
|
||||
* unmounting.
|
||||
*/
|
||||
export function useLazyAttachmentObjectUrl(): (
|
||||
relativeUrl: string,
|
||||
) => Promise<string> {
|
||||
const current = useRef<string>();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useCallback(async (relativeUrl: string) => {
|
||||
const blob = await supportApi.fetchAttachment(relativeUrl);
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
current.current = URL.createObjectURL(blob);
|
||||
return current.current;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import type { SupportConversationDto } from "@edr/types";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
SupportConversationDto,
|
||||
SupportMessageDto,
|
||||
SupportMessageListResult,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { supportApi } from "./supportApi";
|
||||
import { supportApi, type SendMessageInput } 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;
|
||||
|
||||
/** Messages per page in the thread. */
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
/** My company's support thread — null until the first message is sent. */
|
||||
export function useConversation(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -17,13 +32,83 @@ export function useConversation(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The thread's messages, oldest first. Empty until the thread exists. */
|
||||
/**
|
||||
* The thread's messages, paged backwards from newest.
|
||||
*
|
||||
* react-query's "next page" is *older* history here, so `pages` runs
|
||||
* newest-block-first and has to be reversed to render top-to-bottom in time
|
||||
* order. Cursor-based rather than offset so a message arriving mid-scroll
|
||||
* doesn't shift the pages already loaded.
|
||||
*/
|
||||
export function useMessages(enabled = true) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: SUPPORT_MESSAGES_KEY,
|
||||
queryFn: () => supportApi.listMessages(),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listMessages({ before: pageParam, limit: MESSAGES_PAGE_SIZE }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// Exposed so the view can tell a prepended history page from a message
|
||||
// appended at the bottom — `messages` changing says nothing about which.
|
||||
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into the cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: the thread is paginated, so that would
|
||||
* refetch *every* page the customer has scrolled back through — making the cost
|
||||
* of each inbound message grow with how far they've read. Page 0 is the newest
|
||||
* block and its items are oldest-first within the block, so the new message
|
||||
* belongs on its end.
|
||||
*
|
||||
* The socket only ever pushes this company's own thread, so there's no id to
|
||||
* match on. Nothing is written when nothing is cached — the panel isn't
|
||||
* rendering it, and a seeded one-message page would carry no `nextCursor` and
|
||||
* strand the history.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than a boolean: the two ways it can decline
|
||||
* to append need opposite handling. A duplicate is the sender's own echo and must
|
||||
* be ignored — refetching there would undo the whole point. "Uncached" means the
|
||||
* thread's first page is still in flight and may have been read on the server
|
||||
* *before* this message existed, so dropping it silently would lose it until
|
||||
* something else happened to refetch; the caller refetches instead. That's cheap
|
||||
* precisely because nothing is loaded yet.
|
||||
*/
|
||||
export type AppendOutcome = "appended" | "duplicate" | "uncached";
|
||||
|
||||
export function appendMessageToCache(
|
||||
qc: QueryClient,
|
||||
message: SupportMessageDto,
|
||||
): AppendOutcome {
|
||||
let outcome: AppendOutcome = "uncached";
|
||||
qc.setQueryData<InfiniteData<SupportMessageListResult>>(
|
||||
SUPPORT_MESSAGES_KEY,
|
||||
(current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
// Our own message arrives twice — once as the POST response, once as the
|
||||
// socket echo. Ignore the duplicate rather than render it twice.
|
||||
if (newest.items.some((m) => m.id === message.id)) {
|
||||
outcome = "duplicate";
|
||||
return current;
|
||||
}
|
||||
outcome = "appended";
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
|
||||
};
|
||||
},
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function useSupportUnreadCount(enabled = true) {
|
||||
@@ -40,9 +125,12 @@ export function useSupportUnreadCount(enabled = true) {
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: string) => supportApi.sendMessage(body),
|
||||
mutationFn: (input: SendMessageInput) => supportApi.sendMessage(input),
|
||||
// The gateway echoes our own message back over the socket, which appends it
|
||||
// to the cache — so don't invalidate the thread here or every send would
|
||||
// refetch every page the customer has scrolled through. The conversation
|
||||
// itself still needs a refresh: the first send is what creates it.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_MESSAGES_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATION_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { io } from "socket.io-client";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import {
|
||||
appendMessageToCache,
|
||||
SUPPORT_CONVERSATION_KEY,
|
||||
SUPPORT_MESSAGES_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
@@ -29,9 +30,9 @@ 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).
|
||||
* only has one thread, so every push belongs to it: the message goes straight
|
||||
* into the thread cache, the conversation and unread badge are refreshed, and
|
||||
* `onMessage` fires (the widget shows a toast when the panel is closed).
|
||||
*/
|
||||
export function useSupportSocket(
|
||||
enabled: boolean,
|
||||
@@ -52,12 +53,23 @@ export function useSupportSocket(
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW, (event: SupportMessageEvent) => {
|
||||
socket.on(
|
||||
SUPPORT_CHAT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: SupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated, and invalidating
|
||||
// it would refetch every page the customer has scrolled back through on
|
||||
// every single inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === "uncached") {
|
||||
// The thread's first page is still loading and may have been read before
|
||||
// this message existed — without this it would go missing until some
|
||||
// unrelated refetch. Cheap: there are no pages to re-fetch yet.
|
||||
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,
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^8.0.0",
|
||||
"jose": "^5.10.0",
|
||||
"minio": "7.1.3",
|
||||
"pg": "^8.21.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -71,6 +72,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Support chat attachments.
|
||||
--
|
||||
-- `SupportMessage.text` becomes nullable so an attachment-only message can say
|
||||
-- "there is no text" instead of smuggling that through an empty string. This is
|
||||
-- a catalog-only change in Postgres — no table rewrite, no long lock.
|
||||
ALTER TABLE "SupportMessage" ALTER COLUMN "text" DROP NOT NULL;
|
||||
|
||||
-- The `attachments` JSONB column has been dead since the init migration: never
|
||||
-- written, never read, absent from every DTO. It is dropped rather than reused —
|
||||
-- an untyped blob gives no file identity, no size accounting, and nothing to
|
||||
-- cascade on delete. Real rows replace it below. (The name is also needed for
|
||||
-- the new relation.)
|
||||
ALTER TABLE "SupportMessage" DROP COLUMN "attachments";
|
||||
|
||||
-- Backs keyset pagination of a thread (newest-first over (createdAt, id)).
|
||||
-- Without it, paging a long thread degrades to a scan per page.
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupportMessage_conversationId_createdAt_idx" ON "SupportMessage"("conversationId", "createdAt");
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SupportAttachment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"messageId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SupportAttachment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SupportAttachment_messageId_idx" ON "SupportAttachment"("messageId");
|
||||
|
||||
-- AddForeignKey
|
||||
-- CASCADE: an attachment has no meaning without its message. (Object bytes in
|
||||
-- MinIO are not reaped by this — deleting messages is not a flow that exists.)
|
||||
ALTER TABLE "SupportAttachment" ADD CONSTRAINT "SupportAttachment_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "SupportMessage"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -917,10 +917,37 @@ model SupportMessage {
|
||||
id String @id @default(uuid())
|
||||
conversationId String
|
||||
sender SupportSender
|
||||
text String
|
||||
attachments Json?
|
||||
/// NULL for an attachment-only message — absence of text is representable
|
||||
/// rather than smuggled through "". The DTO maps NULL -> "".
|
||||
text String?
|
||||
createdAt DateTime @default(now())
|
||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||
attachments SupportAttachment[]
|
||||
/// Backs keyset pagination of a thread (newest-first over (createdAt, id)).
|
||||
/// Without it, paging a long thread degrades to a scan per page.
|
||||
@@index([conversationId, createdAt])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
/// A file posted on a support message.
|
||||
///
|
||||
/// The freight side stores the equivalent in its polymorphic `freight.files`
|
||||
/// table; this app has no such table (and no TypeORM), so chat attachments get a
|
||||
/// purpose-built model rather than a shared one. Bytes live in MinIO — `url` is
|
||||
/// the unsigned object path, signed on read for preview.
|
||||
model SupportAttachment {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
name String
|
||||
mimeType String
|
||||
/// Bytes.
|
||||
size Int
|
||||
/// Unsigned MinIO object URL. Not directly fetchable by a browser — the API
|
||||
/// mints a short-lived signed URL per response.
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
message SupportMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
@@index([messageId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
20
apps/edr-passenger-api/src/modules/storage/minio.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* Mirrors the freight API's MinIO config so both apps read the same env vars and
|
||||
* behave the same against the same object store. Kept as a copy rather than a
|
||||
* shared package because the two APIs share no runtime code today, and a config
|
||||
* package for six fields would be more coupling than it saves.
|
||||
*/
|
||||
export const minioConfig = registerAs('minio', () => ({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'minio-dev.smart.aaca.gov.et',
|
||||
port: parseInt(process.env.MINIO_PORT || '443', 10),
|
||||
useSSL: process.env.MINIO_USE_SSL !== 'false',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||
bucket: process.env.MINIO_BUCKET || 'edr-dev',
|
||||
// Preset the region so presignedGetObject signs URLs locally. Without it the
|
||||
// minio client fires a live GetBucketLocation request on every sign, which
|
||||
// blocks (no timeout) when MinIO is slow and would hang every thread load.
|
||||
region: process.env.MINIO_REGION || 'us-east-1',
|
||||
}));
|
||||
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
90
apps/edr-passenger-api/src/modules/storage/minio.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigType } from '@nestjs/config';
|
||||
import { Client } from 'minio';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
|
||||
/**
|
||||
* Minimal object-storage client for the passenger API.
|
||||
*
|
||||
* A deliberate subset of the freight MinioService — only what chat attachments
|
||||
* need (put, sign, stream, key-from-url). Freight's extra surface (delete,
|
||||
* public URLs for unauthenticated links) is omitted rather than copied
|
||||
* speculatively.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MinioService {
|
||||
private readonly client: Client;
|
||||
private readonly logger = new Logger(MinioService.name);
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(
|
||||
@Inject(minioConfig.KEY)
|
||||
private readonly config: ConfigType<typeof minioConfig>,
|
||||
) {
|
||||
this.bucket = config.bucket;
|
||||
this.client = new Client({
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: config.secretKey,
|
||||
region: config.region,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(objectName: string, buffer: Buffer, contentType: string): Promise<string> {
|
||||
await this.client.putObject(this.bucket, objectName, buffer, buffer.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
return this.getObjectUrl(objectName);
|
||||
}
|
||||
|
||||
/** Unsigned object URL — what gets persisted. Not browser-fetchable. */
|
||||
getObjectUrl(objectName: string): string {
|
||||
const protocol = this.config.useSSL ? 'https' : 'http';
|
||||
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
|
||||
}
|
||||
|
||||
getObjectNameFromUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) throw new NotFoundException('File object path is empty');
|
||||
if (!/^https?:\/\//i.test(trimmed)) return trimmed.replace(/^\/+/, '');
|
||||
|
||||
const url = new URL(trimmed);
|
||||
// pathname percent-encodes the key (a space becomes "%20") but MinIO stores
|
||||
// the literal characters, so decode each segment or a file whose name had
|
||||
// spaces 404s with "specified key does not exist".
|
||||
const parts = url.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment));
|
||||
if (parts[0] === this.bucket) parts.shift();
|
||||
|
||||
const objectName = parts.join('/');
|
||||
if (!objectName) throw new NotFoundException('File object path is empty');
|
||||
return objectName;
|
||||
}
|
||||
|
||||
async getFileStream(objectName: string): Promise<Readable> {
|
||||
return this.client.getObject(this.bucket, objectName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived signed URL for inline preview.
|
||||
*
|
||||
* Unlike the freight twin this does NOT degrade to an unsigned public URL when
|
||||
* signing fails: a chat attachment is another passenger's file, and quietly
|
||||
* handing back a URL that only works if the bucket is world-readable trades a
|
||||
* visible error for a silent access-control surprise. Fail loudly instead.
|
||||
*/
|
||||
async getSignedUrl(objectName: string, expirySeconds: number): Promise<string> {
|
||||
try {
|
||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sign URL for ${objectName}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/storage/storage.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { minioConfig } from './minio.config';
|
||||
import { MinioService } from './minio.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(minioConfig)],
|
||||
providers: [MinioService],
|
||||
exports: [MinioService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SUPPORT_ATTACHMENT_MAX_BYTES, SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||
|
||||
/** Multipart field name carrying chat files. */
|
||||
export const SUPPORT_ATTACHMENT_FIELD = 'attachments';
|
||||
|
||||
/**
|
||||
* Multer-level caps for the chat send routes.
|
||||
*
|
||||
* These duplicate `SupportService.assertSendable` on purpose and don't replace
|
||||
* it: Multer stops reading the socket once a part exceeds `fileSize`, so an
|
||||
* oversized upload is cut off mid-stream rather than buffered into memory and
|
||||
* rejected afterwards. The service check produces the readable error.
|
||||
*/
|
||||
export const supportAttachmentMulterOptions: MulterOptions = {
|
||||
limits: {
|
||||
fileSize: SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
files: SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
},
|
||||
};
|
||||
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
52
apps/edr-passenger-api/src/modules/support/message-cursor.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Keyset cursor for paging a thread backwards from newest.
|
||||
*
|
||||
* The sort key is the pair `(createdAt, id)`, not `createdAt` alone: two
|
||||
* messages can share a millisecond, and a cursor on a non-unique key either
|
||||
* re-serves or skips the tied rows depending which side of the boundary they
|
||||
* land on. The id breaks ties with a stable total order.
|
||||
*
|
||||
* Deliberately a twin of the freight API's `message-cursor.ts`, not a shared
|
||||
* import: the two APIs share no runtime package, and @edr/types is Nest-free by
|
||||
* design (this throws Nest exceptions). The wire format matches so a client can
|
||||
* treat both chats identically — keep them in step if either changes.
|
||||
*/
|
||||
export interface MessageCursor {
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function encodeMessageCursor(cursor: MessageCursor): string {
|
||||
return Buffer.from(`${cursor.createdAt.toISOString()}|${cursor.id}`, 'utf8').toString(
|
||||
'base64url',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a client-supplied cursor. Rejects anything malformed rather than
|
||||
* silently falling back to "first page" — a corrupted cursor that degrades to
|
||||
* page 1 makes an infinite scroll loop forever over the same rows.
|
||||
*/
|
||||
export function decodeMessageCursor(raw: string): MessageCursor {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(raw, 'base64url').toString('utf8');
|
||||
} catch {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const separator = decoded.lastIndexOf('|');
|
||||
if (separator === -1) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
const createdAt = new Date(decoded.slice(0, separator));
|
||||
const id = decoded.slice(separator + 1);
|
||||
if (Number.isNaN(createdAt.getTime()) || !id) {
|
||||
throw new BadRequestException('Malformed pagination cursor.');
|
||||
}
|
||||
|
||||
return { createdAt, id };
|
||||
}
|
||||
@@ -7,21 +7,33 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
|
||||
import { SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
|
||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||
import { SupportService } from './support.service';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
supportAttachmentMulterOptions,
|
||||
} from './attachment-upload.options';
|
||||
import {
|
||||
CreateConversationDto,
|
||||
CreateGuestConversationDto,
|
||||
DeviceIdBodyDto,
|
||||
DeviceSendMessageDto,
|
||||
DeviceThreadQueryDto,
|
||||
GuestIdBodyDto,
|
||||
GuestSendMessageDto,
|
||||
ListConversationsQueryDto,
|
||||
ListMessagesQueryDto,
|
||||
SendMessageDto,
|
||||
UpdateStatusDto,
|
||||
} from './support.dto';
|
||||
@@ -32,6 +44,35 @@ function userId(req: any): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart send routes accept `text` + `attachments` file parts; a plain-JSON
|
||||
* body still works (Multer passes non-multipart requests through untouched), so
|
||||
* text-only clients are unaffected.
|
||||
*/
|
||||
const attachmentsInterceptor = () =>
|
||||
UseInterceptors(
|
||||
FilesInterceptor(
|
||||
SUPPORT_ATTACHMENT_FIELD,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
supportAttachmentMulterOptions,
|
||||
),
|
||||
);
|
||||
|
||||
/** Swagger body schema for a send route: optional text + optional files. */
|
||||
const sendBodySchema = (extra: Record<string, unknown> = {}) => ({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...extra,
|
||||
text: { type: 'string' },
|
||||
attachments: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
@@ -74,19 +115,37 @@ export class SupportController {
|
||||
@Get('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in one of my conversations' })
|
||||
messages(@Req() req: any, @Param('id') id: string) {
|
||||
return this.service.getMessages(id, { iamUserId: userId(req) });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in one of my conversations (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
messages(@Req() req: any, @Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query, { iamUserId: userId(req) });
|
||||
}
|
||||
|
||||
@Post('conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Send a message as the customer' })
|
||||
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
iamUserId: userId(req),
|
||||
});
|
||||
send(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ iamUserId: userId(req) },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('conversations/:id/read')
|
||||
@@ -111,16 +170,29 @@ export class SupportController {
|
||||
|
||||
@Get('device/thread')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: "Get the device's support thread + messages" })
|
||||
deviceThread(@Query('deviceId') deviceId: string) {
|
||||
return this.service.getDeviceThread(deviceId);
|
||||
@ApiOperation({
|
||||
summary: "Get the device's support thread + its newest page of messages",
|
||||
description:
|
||||
'`messages` is the newest page only, not the whole thread — page back ' +
|
||||
'with `nextCursor` via this same route.',
|
||||
})
|
||||
deviceThread(@Query() query: DeviceThreadQueryDto) {
|
||||
return this.service.getDeviceThread(query.deviceId, query);
|
||||
}
|
||||
|
||||
@Post('device/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
|
||||
deviceSend(@Body() body: DeviceSendMessageDto) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ deviceId: { type: 'string' } }))
|
||||
@ApiOperation({
|
||||
summary: 'Send a message (creates the thread on first send)',
|
||||
})
|
||||
deviceSend(
|
||||
@Body() body: DeviceSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendDeviceMessage(body.deviceId, body.text, attachments ?? []);
|
||||
}
|
||||
|
||||
@Post('device/read')
|
||||
@@ -137,6 +209,37 @@ export class SupportController {
|
||||
return this.service.unreadCount('USER', { guestId: deviceId });
|
||||
}
|
||||
|
||||
// ---- chat attachments --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Serves both audiences (portal device threads and the backoffice inbox) from
|
||||
* one path, because a new message is pushed to both over the socket in a
|
||||
* single payload — an identity-bearing URL would be wrong for one of them.
|
||||
*
|
||||
* Public for the same reason the device thread is: access to a passenger
|
||||
* support thread is already whoever-holds-the-id. See `streamAttachment` for
|
||||
* the full trade-off and the TODO to tighten it with the agent-route gating.
|
||||
*/
|
||||
@Get('attachments/:fileId')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Stream a support chat attachment' })
|
||||
async attachment(
|
||||
@Param('fileId') fileId: string,
|
||||
@Query('download') download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, mimeType, name } = await this.service.streamAttachment(fileId);
|
||||
const forceDownload = download === '1' || download === 'true';
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${forceDownload ? 'attachment' : 'inline'}; filename="${name}"`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
|
||||
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
|
||||
// of access — anyone with it sees that thread; accepted MVP trade-off).
|
||||
@@ -150,28 +253,42 @@ export class SupportController {
|
||||
|
||||
@Get('guest/conversations')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List a guest\'s conversations' })
|
||||
guestList(
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListConversationsQueryDto,
|
||||
) {
|
||||
@ApiOperation({ summary: "List a guest's conversations" })
|
||||
guestList(@Query('guestId') guestId: string, @Query() query: ListConversationsQueryDto) {
|
||||
return this.service.listForCustomer({ guestId }, query);
|
||||
}
|
||||
|
||||
@Get('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'List messages in a guest conversation' })
|
||||
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
|
||||
return this.service.getMessages(id, { guestId });
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a guest conversation (newest page first)',
|
||||
})
|
||||
guestMessages(
|
||||
@Param('id') id: string,
|
||||
@Query('guestId') guestId: string,
|
||||
@Query() query: ListMessagesQueryDto,
|
||||
) {
|
||||
return this.service.getMessages(id, query, { guestId });
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/messages')
|
||||
@IsPublic()
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema({ guestId: { type: 'string' } }))
|
||||
@ApiOperation({ summary: 'Send a message as a guest' })
|
||||
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
|
||||
return this.service.sendMessage(id, 'USER', body.text, {
|
||||
guestId: body.guestId,
|
||||
});
|
||||
guestSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: GuestSendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(
|
||||
id,
|
||||
'USER',
|
||||
body.text,
|
||||
{ guestId: body.guestId },
|
||||
attachments ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('guest/conversations/:id/read')
|
||||
@@ -183,7 +300,7 @@ export class SupportController {
|
||||
|
||||
@Get('guest/unread-count')
|
||||
@IsPublic()
|
||||
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
|
||||
@ApiOperation({ summary: "Count a guest's unread conversations" })
|
||||
guestUnread(@Query('guestId') guestId: string) {
|
||||
return this.service.unreadCount('USER', { guestId });
|
||||
}
|
||||
@@ -202,17 +319,29 @@ export class SupportController {
|
||||
@Get('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'List messages in a conversation' })
|
||||
agentMessages(@Param('id') id: string) {
|
||||
return this.service.getMessages(id);
|
||||
@ApiOperation({
|
||||
summary: 'List messages in a conversation (newest page first)',
|
||||
description:
|
||||
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
|
||||
'page, then pass the previous `nextCursor`. Null means start of thread.',
|
||||
})
|
||||
agentMessages(@Param('id') id: string, @Query() query: ListMessagesQueryDto) {
|
||||
return this.service.getMessages(id, query);
|
||||
}
|
||||
|
||||
@Post('agent/conversations/:id/messages')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reply as an agent' })
|
||||
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text);
|
||||
@attachmentsInterceptor()
|
||||
@ApiConsumes('multipart/form-data', 'application/json')
|
||||
@ApiBody(sendBodySchema())
|
||||
@ApiOperation({ summary: 'Reply as an agent, optionally with attachments' })
|
||||
agentSend(
|
||||
@Param('id') id: string,
|
||||
@Body() body: SendMessageDto,
|
||||
@UploadedFiles() attachments?: Express.Multer.File[],
|
||||
) {
|
||||
return this.service.sendMessage(id, 'AGENT', body.text, undefined, attachments ?? []);
|
||||
}
|
||||
|
||||
@Patch('agent/conversations/:id/status')
|
||||
|
||||
@@ -32,12 +32,19 @@ export class CreateConversationDto {
|
||||
initialMessage!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text is optional across the send DTOs because a message may be nothing but
|
||||
* attachments. "Neither text nor files" is rejected in the service rather than
|
||||
* here — the validator can't see the multipart file parts.
|
||||
*/
|
||||
export class SendMessageDto {
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class CreateGuestConversationDto {
|
||||
@@ -79,11 +86,13 @@ export class GuestSendMessageDto {
|
||||
@Length(8, 120)
|
||||
guestId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class GuestIdBodyDto {
|
||||
@@ -99,11 +108,13 @@ export class DeviceSendMessageDto {
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Message text.' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Message text. Optional only when attachments are present.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(4000)
|
||||
text!: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export class DeviceIdBodyDto {
|
||||
@@ -145,3 +156,38 @@ export class ListConversationsQueryDto {
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Default page size for a thread — roughly two screens of bubbles. */
|
||||
export const SUPPORT_MESSAGES_DEFAULT_LIMIT = 30;
|
||||
export const SUPPORT_MESSAGES_MAX_LIMIT = 100;
|
||||
|
||||
export class ListMessagesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Opaque cursor from a previous response's `nextCursor`. Returns the page " +
|
||||
'of messages immediately OLDER than the cursor. Omit for the newest page.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
before?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
maximum: SUPPORT_MESSAGES_MAX_LIMIT,
|
||||
default: SUPPORT_MESSAGES_DEFAULT_LIMIT,
|
||||
})
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(SUPPORT_MESSAGES_MAX_LIMIT)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Query form of {@link ListMessagesQueryDto} for the device-scoped thread. */
|
||||
export class DeviceThreadQueryDto extends ListMessagesQueryDto {
|
||||
@ApiProperty({ description: 'Client device id (localStorage).' })
|
||||
@IsString()
|
||||
@Length(8, 120)
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
|
||||
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportService } from './support.service';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
@@ -10,7 +11,8 @@ import { WsAuthService } from './ws-auth.service';
|
||||
@Module({
|
||||
// Session is served by the app's default TypeORM DataSource (IAM schema) —
|
||||
// used by WsAuthService to authenticate WebSocket handshakes.
|
||||
imports: [TypeOrmModule.forFeature([Session])],
|
||||
// StorageModule — MinioService for chat attachment bytes.
|
||||
imports: [TypeOrmModule.forFeature([Session]), StorageModule],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportGateway, WsAuthService],
|
||||
})
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Passenger as T } from '@edr/types';
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
Passenger as T,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from '@edr/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Readable } from 'stream';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { MinioService } from '../storage/minio.service';
|
||||
import { decodeMessageCursor, encodeMessageCursor } from './message-cursor';
|
||||
import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto';
|
||||
import { SupportGateway } from './support.gateway';
|
||||
|
||||
type Side = 'USER' | 'AGENT';
|
||||
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
|
||||
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
|
||||
|
||||
/** Stand-in preview for a message that is nothing but files. */
|
||||
const ATTACHMENT_ONLY_PREVIEW = '📎';
|
||||
|
||||
interface MessagesQuery {
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
type AttachmentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type MessageRow = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string | null;
|
||||
createdAt: Date;
|
||||
attachments?: AttachmentRow[];
|
||||
};
|
||||
|
||||
/** Who the caller is on the customer side: an authed passenger or a guest. */
|
||||
export interface CustomerOwner {
|
||||
iamUserId?: string | null;
|
||||
@@ -50,6 +86,7 @@ export class SupportService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private gateway: SupportGateway,
|
||||
private minio: MinioService,
|
||||
) {}
|
||||
|
||||
// ---- FAQ (unchanged) ---------------------------------------------------
|
||||
@@ -115,29 +152,37 @@ export class SupportService {
|
||||
|
||||
// ---- customer: device-scoped single thread (portal) -------------------
|
||||
|
||||
/** The device's single conversation + its messages ({conversation:null} if none). */
|
||||
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
|
||||
if (!deviceId) return { conversation: null, messages: [] };
|
||||
/**
|
||||
* The device's single conversation + its NEWEST page of messages
|
||||
* ({conversation:null} if none). Not the whole thread — the client pages back
|
||||
* with `nextCursor` exactly as the backoffice does.
|
||||
*/
|
||||
async getDeviceThread(
|
||||
deviceId: string,
|
||||
query: MessagesQuery = {},
|
||||
): Promise<T.PassengerSupportThreadDto> {
|
||||
const empty = { conversation: null, messages: [], nextCursor: null };
|
||||
if (!deviceId) return empty;
|
||||
const c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as ConversationRow | null;
|
||||
if (!c) return { conversation: null, messages: [] };
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId: c.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!c) return empty;
|
||||
|
||||
const page = await this.listMessages(c.id, query);
|
||||
const unread = await this.computeUnread([c], 'USER');
|
||||
return {
|
||||
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
|
||||
messages: rows.map((m) => this.toMessageDto(m)),
|
||||
messages: page.items,
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a message to the device's thread, creating it on first message. */
|
||||
async sendDeviceMessage(
|
||||
deviceId: string,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
let c = (await this.prisma.supportConversation.findFirst({
|
||||
where: { guestId: deviceId },
|
||||
@@ -148,9 +193,7 @@ export class SupportService {
|
||||
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
|
||||
})) as ConversationRow;
|
||||
}
|
||||
const updated = await this.appendMessage(c, 'USER', text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(c, 'USER', text, attachments);
|
||||
}
|
||||
|
||||
/** Mark the device's thread read (customer side). */
|
||||
@@ -186,9 +229,7 @@ export class SupportService {
|
||||
|
||||
// ---- agent (backoffice) ------------------------------------------------
|
||||
|
||||
async listForAgents(
|
||||
query: ListQuery,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
async listForAgents(query: ListQuery): Promise<T.PassengerSupportConversationListResult> {
|
||||
const where = this.listWhere(query);
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where,
|
||||
@@ -216,32 +257,29 @@ export class SupportService {
|
||||
|
||||
// ---- shared ------------------------------------------------------------
|
||||
|
||||
/** One page of a thread, newest first. See {@link listMessages}. */
|
||||
async getMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery = {},
|
||||
asCustomer?: CustomerOwner,
|
||||
): Promise<T.PassengerSupportMessageDto[]> {
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (asCustomer) this.assertOwns(conversation, asCustomer);
|
||||
const rows = await this.prisma.supportMessage.findMany({
|
||||
where: { conversationId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
return rows.map((m) => this.toMessageDto(m));
|
||||
return this.listMessages(conversationId, query);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
conversationId: string,
|
||||
sender: Side,
|
||||
text: string,
|
||||
text: string | undefined,
|
||||
asCustomer?: CustomerOwner,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const conversation = await this.requireConversation(conversationId);
|
||||
if (sender === 'USER') {
|
||||
this.assertOwns(conversation, asCustomer ?? {});
|
||||
}
|
||||
const updated = await this.appendMessage(conversation, sender, text);
|
||||
const last = updated.messages[updated.messages.length - 1];
|
||||
return this.toMessageDto(last);
|
||||
return this.appendMessage(conversation, sender, text, attachments);
|
||||
}
|
||||
|
||||
async markRead(
|
||||
@@ -265,10 +303,7 @@ export class SupportService {
|
||||
return this.unreadCount('AGENT');
|
||||
}
|
||||
|
||||
async unreadCount(
|
||||
side: Side,
|
||||
owner?: CustomerOwner,
|
||||
): Promise<{ unreadCount: number }> {
|
||||
async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> {
|
||||
const rows = (await this.prisma.supportConversation.findMany({
|
||||
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
|
||||
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
|
||||
@@ -289,49 +324,197 @@ export class SupportService {
|
||||
conversation: ConversationRow,
|
||||
text: string,
|
||||
): Promise<T.PassengerSupportConversationDto> {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
'USER',
|
||||
text,
|
||||
);
|
||||
const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text);
|
||||
return this.toConversationDto(updated, 0);
|
||||
}
|
||||
|
||||
private async appendMessage(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
) {
|
||||
const { conversation: updated } = await this.appendMessageRaw(
|
||||
conversation,
|
||||
sender,
|
||||
text,
|
||||
);
|
||||
return updated as ConversationRow & { messages: any[] };
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<T.PassengerSupportMessageDto> {
|
||||
const { message } = await this.appendMessageRaw(conversation, sender, text, attachments);
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Persist a message, bump the conversation's denormalized fields, emit live. */
|
||||
/**
|
||||
* One page of a thread, walking backwards from newest.
|
||||
*
|
||||
* Keyset, not offset: a message arriving while the reader is scrolled back
|
||||
* would shift every offset by one and duplicate/skip rows across pages. Rides
|
||||
* the (conversationId, createdAt) index; the id is a tiebreak for messages
|
||||
* sharing a millisecond.
|
||||
*/
|
||||
private async listMessages(
|
||||
conversationId: string,
|
||||
query: MessagesQuery,
|
||||
): Promise<T.PassengerSupportMessageListResult> {
|
||||
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
|
||||
const before = query.before ? decodeMessageCursor(query.before) : undefined;
|
||||
|
||||
const rows = (await this.prisma.supportMessage.findMany({
|
||||
where: {
|
||||
conversationId,
|
||||
...(before
|
||||
? {
|
||||
// Strictly older than the cursor in (createdAt, id) order.
|
||||
OR: [
|
||||
{ createdAt: { lt: before.createdAt } },
|
||||
{
|
||||
createdAt: before.createdAt,
|
||||
id: { lt: before.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
// One more than asked, to tell "there's another page" from "this page was
|
||||
// simply full" without a second COUNT.
|
||||
take: limit + 1,
|
||||
include: { attachments: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as MessageRow[];
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const oldest = page[page.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && oldest
|
||||
? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id })
|
||||
: null;
|
||||
|
||||
// Flip to oldest-first so a page prepends as one block.
|
||||
const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m)));
|
||||
return { items, nextCursor };
|
||||
}
|
||||
|
||||
/** Persist a message (+ attachments), bump denormalized fields, emit live. */
|
||||
private async appendMessageRaw(
|
||||
conversation: ConversationRow,
|
||||
sender: PrismaSender,
|
||||
text: string,
|
||||
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
|
||||
text: string | undefined,
|
||||
attachments: Express.Multer.File[] = [],
|
||||
): Promise<{
|
||||
conversation: ConversationRow;
|
||||
message: T.PassengerSupportMessageDto;
|
||||
}> {
|
||||
const trimmed = (text ?? '').trim();
|
||||
this.assertSendable(trimmed, attachments);
|
||||
|
||||
const message = await this.prisma.supportMessage.create({
|
||||
data: { conversationId: conversation.id, sender, text },
|
||||
// NULL, not "", so "no text" is representable rather than inferred. The
|
||||
// DTO flattens it back to "" for rendering.
|
||||
data: { conversationId: conversation.id, sender, text: trimmed || null },
|
||||
});
|
||||
|
||||
// The row has to exist before the files, since each is stored against
|
||||
// `messageId`. That leaves a window: if an upload fails here, the message is
|
||||
// already committed. Undo it rather than leave the thread with a
|
||||
// permanently blank bubble — there is no delete flow, so an orphan would be
|
||||
// unremovable, and an attachment-only message that lost its files has no
|
||||
// content at all.
|
||||
let stored: AttachmentRow[];
|
||||
try {
|
||||
stored = await this.storeAttachments(message.id, attachments);
|
||||
} catch (error) {
|
||||
await this.prisma.supportMessage.delete({ where: { id: message.id } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const updated = (await this.prisma.supportConversation.update({
|
||||
where: { id: conversation.id },
|
||||
data: {
|
||||
lastMessageAt: message.createdAt,
|
||||
lastMessagePreview: text.slice(0, 280),
|
||||
lastMessagePreview: this.buildPreview(trimmed, stored),
|
||||
lastMessageSender: sender,
|
||||
},
|
||||
include: { messages: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as ConversationRow & { messages: any[] };
|
||||
// Deliberately NOT `include: { messages: ... }` — that loaded every
|
||||
// message in the thread on every send just to read back the one we had in
|
||||
// hand.
|
||||
})) as ConversationRow;
|
||||
|
||||
const messageDto = await this.toMessageDto({
|
||||
...(message as MessageRow),
|
||||
attachments: stored,
|
||||
});
|
||||
const dto = this.toConversationDto(updated, 0);
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
|
||||
return { conversation: updated, message };
|
||||
this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto);
|
||||
return { conversation: updated, message: messageDto };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push bytes to MinIO, then record them. Object keys are namespaced by message
|
||||
* id and the stored name is sanitized so the key survives the round-trip
|
||||
* through its own URL (spaces/unicode would otherwise percent-encode and no
|
||||
* longer match the key).
|
||||
*/
|
||||
private async storeAttachments(
|
||||
messageId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<AttachmentRow[]> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const safeName = file.originalname
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w.\-]+/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
// The random segment is load-bearing: `Date.now()` is NOT unique across
|
||||
// this batch, since every callback runs to its first await in the same
|
||||
// tick and reads the same millisecond. Two files sharing a name — e.g.
|
||||
// two pasted screenshots, which browsers both call "image.png" — would
|
||||
// otherwise build the same key and silently overwrite each other.
|
||||
const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
|
||||
const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
return this.prisma.supportAttachment.create({
|
||||
data: {
|
||||
messageId,
|
||||
name: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
size: file.size,
|
||||
url,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat upload rules — kept in step with the freight side via the shared
|
||||
* SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup
|
||||
* and this is a file one user pushes at another.
|
||||
*/
|
||||
private assertSendable(text: string, attachments: Express.Multer.File[]): void {
|
||||
if (!text && attachments.length === 0) {
|
||||
throw new BadRequestException('A message needs text or at least one attachment.');
|
||||
}
|
||||
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
throw new BadRequestException(
|
||||
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
|
||||
);
|
||||
}
|
||||
for (const file of attachments) {
|
||||
if (!isSupportAttachmentAllowed(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported attachment type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`"${file.originalname}" exceeds the ${
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
|
||||
}MB attachment limit.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inbox preview line — falls back to filenames when there's no text. */
|
||||
private buildPreview(text: string, attachments: AttachmentRow[]): string {
|
||||
if (text) return text.slice(0, 280);
|
||||
if (attachments.length === 1) {
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
|
||||
}
|
||||
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
|
||||
}
|
||||
|
||||
private async buildListResult(
|
||||
@@ -340,9 +523,7 @@ export class SupportService {
|
||||
side: Side,
|
||||
): Promise<T.PassengerSupportConversationListResult> {
|
||||
const unreadMap = await this.computeUnread(rows, side);
|
||||
const items = rows.map((r) =>
|
||||
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
|
||||
);
|
||||
const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0));
|
||||
let unreadCount = 0;
|
||||
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
|
||||
return { items, count, unreadCount };
|
||||
@@ -365,10 +546,7 @@ export class SupportService {
|
||||
select: { conversationId: true, createdAt: true },
|
||||
});
|
||||
const cursorById = new Map(
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
|
||||
]),
|
||||
rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]),
|
||||
);
|
||||
for (const m of msgs) {
|
||||
const cursor = cursorById.get(m.conversationId) ?? null;
|
||||
@@ -448,27 +626,75 @@ export class SupportService {
|
||||
};
|
||||
}
|
||||
|
||||
private toMessageDto(m: {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: PrismaSender;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
}): T.PassengerSupportMessageDto {
|
||||
private async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
|
||||
return {
|
||||
id: m.id,
|
||||
conversationId: m.conversationId,
|
||||
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
|
||||
text: m.text,
|
||||
text: m.text ?? '',
|
||||
attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)),
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the client fetches the bytes: this API's own stream route, NOT a
|
||||
* presigned MinIO URL. Presigned object URLs are not reachable from the
|
||||
* browser in this deployment, which is why every working file in the platform
|
||||
* streams through the API instead.
|
||||
*
|
||||
* The path is audience-independent on purpose. A new message is pushed over
|
||||
* the socket to the device room *and* the backoffice room in one payload, so a
|
||||
* URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong
|
||||
* for one of the two recipients.
|
||||
*
|
||||
* The client can't use this path as an `<img src>` either — the agent side's
|
||||
* guard only reads a bearer header, which an image request can't send — so the
|
||||
* web apps fetch it through their authenticated client and render a blob.
|
||||
*/
|
||||
private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto {
|
||||
return {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
url: `/support/attachments/${a.id}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes for a chat attachment.
|
||||
*
|
||||
* Deliberately unscoped, and this is a trade-off worth naming: passenger
|
||||
* support threads are already reachable by whoever holds the device/guest id
|
||||
* (see the device routes — "anyone with the device id can see that thread",
|
||||
* the accepted MVP posture), and the agent routes admit any authenticated
|
||||
* caller pending real staff gating. Scoping this endpoint tighter than the
|
||||
* thread it belongs to would buy nothing, so it matches that posture: the
|
||||
* attachment UUID is the capability.
|
||||
*
|
||||
* TODO: tighten alongside the agent-route staff permission — at that point
|
||||
* both the thread and its attachments should be gated the same way.
|
||||
*/
|
||||
async streamAttachment(
|
||||
fileId: string,
|
||||
): Promise<{ stream: Readable; mimeType: string; name: string }> {
|
||||
const attachment = await this.prisma.supportAttachment.findUnique({
|
||||
where: { id: fileId },
|
||||
});
|
||||
if (!attachment) throw new NotFoundException('Attachment not found');
|
||||
|
||||
const objectName = this.minio.getObjectNameFromUrl(attachment.url);
|
||||
return {
|
||||
stream: await this.minio.getFileStream(objectName),
|
||||
mimeType: attachment.mimeType,
|
||||
name: attachment.name,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy BOT messages are surfaced as AGENT to the UI. */
|
||||
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
|
||||
if (!s) return null;
|
||||
return s === 'USER'
|
||||
? T.PassengerSupportSender.USER
|
||||
: T.PassengerSupportSender.AGENT;
|
||||
return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Passenger } from '@edr/types';
|
||||
import { Headset, Search, Send, User } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Passenger, SUPPORT_ATTACHMENT_ACCEPT } from '@edr/types';
|
||||
import { Headset, Paperclip, Search, Send, User, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { AttachmentDraftBar } from '@/features/support/AttachmentDraftBar';
|
||||
import { MessageAttachments } from '@/features/support/MessageAttachments';
|
||||
import { useAttachmentDraft } from '@/features/support/useAttachmentDraft';
|
||||
import { useLazyAttachmentObjectUrl } from '@/features/support/useAttachmentObjectUrl';
|
||||
import { useFilePreview } from '@/features/support/useFilePreview';
|
||||
import {
|
||||
useConversations,
|
||||
useMarkRead,
|
||||
@@ -16,6 +21,10 @@ import { useSupportSocket } from '@/features/support/useSupportSocket';
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** Distance from an edge (px) that counts as "at" it. */
|
||||
const SCROLL_EDGE_SLOP = 120;
|
||||
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
OPEN: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
@@ -39,10 +48,9 @@ export default function SupportPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useConversations(
|
||||
const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = useConversations(
|
||||
status === 'ALL' ? { search } : { status, search },
|
||||
);
|
||||
const items = useMemo(() => data?.items ?? [], [data?.items]);
|
||||
|
||||
useSupportSocket(true);
|
||||
|
||||
@@ -51,6 +59,17 @@ export default function SupportPage() {
|
||||
[items, selectedId],
|
||||
);
|
||||
|
||||
// Pull the next page in as the agent nears the end of the list. The list
|
||||
// previously rendered a single fetch, so any thread past the server's default
|
||||
// page was simply unreachable.
|
||||
const onInboxScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
const el = e.currentTarget;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_EDGE_SLOP) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -73,10 +92,7 @@ export default function SupportPage() {
|
||||
<div className="flex w-[340px] shrink-0 flex-col border-r border-border">
|
||||
<div className="space-y-2 border-b border-border p-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-2.5 text-muted-foreground"
|
||||
/>
|
||||
<Search size={16} className="absolute left-3 top-2.5 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -90,9 +106,7 @@ export default function SupportPage() {
|
||||
key={f}
|
||||
onClick={() => setStatus(f)}
|
||||
className={`flex-1 rounded-md px-2 py-1 text-xs font-medium capitalize transition ${
|
||||
status === f
|
||||
? 'text-white'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
status === f ? 'text-white' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
style={status === f ? { background: GREEN } : undefined}
|
||||
>
|
||||
@@ -101,24 +115,25 @@ export default function SupportPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" onScroll={onInboxScroll}>
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No conversations.
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No conversations.</div>
|
||||
) : (
|
||||
items.map((c) => (
|
||||
<>
|
||||
{items.map((c) => (
|
||||
<InboxRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
active={c.id === selectedId}
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
/>
|
||||
))
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="p-3 text-center text-xs text-muted-foreground">Loading more…</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +175,9 @@ function InboxRow({
|
||||
className={`block w-full border-b border-border px-4 py-3 text-left transition hover:bg-muted/50 ${
|
||||
active ? 'bg-muted/70' : ''
|
||||
}`}
|
||||
style={active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }}
|
||||
style={
|
||||
active ? { borderLeft: `3px solid ${GREEN}` } : { borderLeft: '3px solid transparent' }
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
@@ -204,31 +221,131 @@ function InboxRow({
|
||||
}
|
||||
|
||||
function ConversationThread({ conversation }: { conversation: ConversationDto }) {
|
||||
const { data: messages, isLoading } = useMessages(conversation.id);
|
||||
const { messages, pageCount, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useMessages(conversation.id);
|
||||
const send = useSendMessage(conversation.id);
|
||||
const setStatus = useSetStatus();
|
||||
const markRead = useMarkRead();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const { view, viewer } = useFilePreview();
|
||||
const attach = useAttachmentDraft(setError);
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
/**
|
||||
* Scroll height captured just before an older page was requested, tagged with
|
||||
* the page count at that moment.
|
||||
*
|
||||
* The page count is what makes this safe. Keyed on presence alone, a message
|
||||
* arriving over the socket while history was still in flight would consume the
|
||||
* snapshot on a one-bubble append, and the real 30-message prepend would then
|
||||
* land with nothing to correct against — throwing the reader exactly as far as
|
||||
* this exists to prevent. Comparing counts means only an actual new page can
|
||||
* claim it.
|
||||
*/
|
||||
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(null);
|
||||
/** Whether the agent is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
/** Which thread the refs above describe; a switch resets them. */
|
||||
const anchoredThread = useRef(conversation.id);
|
||||
|
||||
const messageCount = messages.length;
|
||||
|
||||
useEffect(() => {
|
||||
markRead.mutate(conversation.id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation.id, messages?.length]);
|
||||
}, [conversation.id, messageCount]);
|
||||
|
||||
// A send failure belongs to the thread it was typed in, not the next one.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages?.length, conversation.id]);
|
||||
setError(null);
|
||||
}, [conversation.id]);
|
||||
|
||||
/**
|
||||
* Keep the viewport sensible as the list changes underneath it.
|
||||
*
|
||||
* Two different things change `messages`, and they want opposite behaviour:
|
||||
* a new message at the bottom should follow (if the agent is already there),
|
||||
* while an older page prepended at the top must NOT move the content the
|
||||
* agent is reading. Layout effect, not effect — this has to run before paint
|
||||
* or the prepend visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
|
||||
// Thread switch: start a fresh read at the bottom and drop the previous
|
||||
// thread's anchoring state.
|
||||
if (anchoredThread.current !== conversation.id) {
|
||||
anchoredThread.current = conversation.id;
|
||||
pendingRestore.current = null;
|
||||
stick.current = true;
|
||||
el.scrollTo({ top: el.scrollHeight });
|
||||
return;
|
||||
}
|
||||
|
||||
const restore = pendingRestore.current;
|
||||
if (restore && pageCount > restore.atPageCount) {
|
||||
// An older page went in above: push the scroll down by exactly the height
|
||||
// that was added, so the same message stays under the cursor.
|
||||
el.scrollTop += el.scrollHeight - restore.height;
|
||||
pendingRestore.current = null;
|
||||
return;
|
||||
}
|
||||
if (stick.current) el.scrollTo({ top: el.scrollHeight });
|
||||
}, [messages, pageCount, conversation.id]);
|
||||
|
||||
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (el.scrollTop < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
|
||||
// A failed fetch leaves this set, which is harmless: the list didn't
|
||||
// change, so the height is still accurate for the retry, and the count
|
||||
// tag stops it being mistaken for a landed page in the meantime.
|
||||
pendingRestore.current = {
|
||||
height: el.scrollHeight,
|
||||
atPageCount: pageCount,
|
||||
};
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
if (!text && attach.attachments.length === 0) return;
|
||||
const files = attach.files;
|
||||
// Clear optimistically so the composer feels instant; on failure the text
|
||||
// is restored below rather than silently lost.
|
||||
setDraft('');
|
||||
await send.mutateAsync(text);
|
||||
attach.clear();
|
||||
setError(null);
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ text: text || undefined, attachments: files });
|
||||
} catch (err) {
|
||||
setDraft(text);
|
||||
setError(err instanceof Error ? err.message : "Couldn't send that message.");
|
||||
}
|
||||
};
|
||||
|
||||
const changeStatus = (status: string) =>
|
||||
setStatus.mutate({ id: conversation.id, status });
|
||||
const changeStatus = (status: string) => setStatus.mutate({ id: conversation.id, status });
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const openAttachment = (a: AttachmentDto, src: string) =>
|
||||
view({ name: a.name, url: src, mimeType: a.mimeType });
|
||||
|
||||
// Documents aren't fetched until opened.
|
||||
const openFile = async (a: AttachmentDto) => {
|
||||
try {
|
||||
view({ name: a.name, url: await loadAttachment(a.url), mimeType: a.mimeType });
|
||||
} catch {
|
||||
setError(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -282,21 +399,85 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
<div ref={viewport} onScroll={onScroll} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">No messages yet.</div>
|
||||
) : (
|
||||
(messages ?? []).map((m) => <AgentBubble key={m.id} m={m} />)
|
||||
<>
|
||||
{isFetchingNextPage && (
|
||||
<div className="py-2 text-center text-xs text-muted-foreground">
|
||||
Loading earlier messages…
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<div className="text-center text-[10px] text-muted-foreground">
|
||||
Start of conversation
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<AgentBubble key={m.id} m={m} onView={openAttachment} onOpenFile={openFile} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<div
|
||||
className={`border-t border-border p-3 ${
|
||||
dragging
|
||||
? 'bg-emerald-50 outline-dashed outline-2 -outline-offset-4 outline-emerald-600 dark:bg-emerald-900/20'
|
||||
: ''
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 rounded-lg bg-red-100 px-3 py-1.5 text-xs text-red-700 dark:bg-red-900/40 dark:text-red-300">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} aria-label="Dismiss">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentDraftBar attachments={attach.attachments} onRemove={attach.remove} />
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
attach.add(Array.from(e.currentTarget.files ?? []));
|
||||
// Reset so picking the same file twice in a row still fires change.
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label="Attach files"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-muted"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Screenshots land on the clipboard as files. Take them and
|
||||
// suppress the default, which would otherwise also paste the
|
||||
// image's name (or nothing) as text.
|
||||
onPaste={(e) => {
|
||||
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -304,12 +485,12 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
|
||||
placeholder="Type your reply, or paste an image… (Enter to send, Shift+Enter for newline)"
|
||||
className="max-h-28 flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
disabled={(!draft.trim() && attach.attachments.length === 0) || send.isPending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: GREEN }}
|
||||
aria-label="Send"
|
||||
@@ -318,11 +499,20 @@ function ConversationThread({ conversation }: { conversation: ConversationDto })
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{viewer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentBubble({ m }: { m: MessageDto }) {
|
||||
function AgentBubble({
|
||||
m,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: MessageDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.sender === 'AGENT';
|
||||
return (
|
||||
<div className={`flex ${mine ? 'justify-end' : 'justify-start'} items-end gap-2`}>
|
||||
@@ -332,22 +522,22 @@ function AgentBubble({ m }: { m: MessageDto }) {
|
||||
</span>
|
||||
)}
|
||||
<div className="max-w-[70%]">
|
||||
<p
|
||||
className={`mb-0.5 text-xs text-muted-foreground ${
|
||||
mine ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
<p className={`mb-0.5 text-xs text-muted-foreground ${mine ? 'text-right' : 'text-left'}`}>
|
||||
{mine ? m.authorName || 'You' : m.authorName || 'Passenger'}
|
||||
</p>
|
||||
<div
|
||||
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
|
||||
mine
|
||||
? 'rounded-br-sm text-white'
|
||||
: 'rounded-bl-sm bg-muted text-foreground'
|
||||
className={`rounded-2xl px-3 py-2 text-sm ${
|
||||
mine ? 'rounded-br-sm text-white' : 'rounded-bl-sm bg-muted text-foreground'
|
||||
}`}
|
||||
style={mine ? { background: GREEN } : undefined}
|
||||
>
|
||||
{m.text}
|
||||
{m.text && <p className="whitespace-pre-wrap break-words">{m.text}</p>}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onView={onView}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-0.5 text-[10px] text-muted-foreground ${
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, X } from 'lucide-react';
|
||||
|
||||
import { formatBytes } from './MessageAttachments';
|
||||
import type { PendingAttachment } from './useAttachmentDraft';
|
||||
|
||||
/**
|
||||
* The staged-files strip above the composer. Shows what will be sent and lets
|
||||
* the agent drop any of it before hitting send.
|
||||
*/
|
||||
export function AttachmentDraftBar({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="relative flex items-center gap-1.5 rounded-lg border border-border bg-background p-1 pr-4"
|
||||
>
|
||||
{a.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
className="h-9 w-9 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 max-w-[120px]">
|
||||
<span className="block truncate text-xs font-semibold text-foreground">
|
||||
{a.file.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
{formatBytes(a.file.size)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(a.id)}
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-gray-600 text-white transition hover:bg-gray-700"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage, type Passenger } from '@edr/types';
|
||||
import { FileText, ImageOff } from 'lucide-react';
|
||||
|
||||
import { useAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One image attachment. Its own component because the bytes are fetched through
|
||||
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
||||
* be called from inside a `.map()`.
|
||||
*/
|
||||
function ImageAttachment({
|
||||
a,
|
||||
onView,
|
||||
}: {
|
||||
a: AttachmentDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs opacity-75">
|
||||
<ImageOff size={14} className="shrink-0" />
|
||||
Couldn't load {a.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="h-[120px] w-[200px] animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(a, src)}
|
||||
className="cursor-zoom-in overflow-hidden rounded-lg"
|
||||
aria-label={`View ${a.name}`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={a.name}
|
||||
// Cap the bubble: a tall screenshot would otherwise push the
|
||||
// whole conversation off-screen.
|
||||
className="max-h-[220px] max-w-[260px] rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments inside a message bubble: images as thumbnails, everything else as
|
||||
* a labelled file row. Non-images are not fetched until opened — pulling every
|
||||
* document in a thread just to draw a filename would be wasteful.
|
||||
*/
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
mine,
|
||||
onView,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: AttachmentDto[];
|
||||
mine: boolean;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-col gap-1.5">
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onView} />
|
||||
) : (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition hover:opacity-90 ${
|
||||
mine ? 'bg-white/20' : 'border border-border bg-background'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">{a.name}</span>
|
||||
<span className="block text-[10px] opacity-75">{formatBytes(a.size)}</span>
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,32 +5,78 @@ import { apiClient } from '@/lib/api-client';
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type ListResult = Passenger.PassengerSupportConversationListResult;
|
||||
type MessageListResult = Passenger.PassengerSupportMessageListResult;
|
||||
|
||||
export interface ListParams {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ListMessagesParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
text?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server can persist them against
|
||||
* the message it creates in the same request; text-only stays JSON.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): FormData | { text?: string } {
|
||||
if (!input.attachments?.length) return { text: input.text };
|
||||
|
||||
const form = new FormData();
|
||||
if (input.text) form.append('text', input.text);
|
||||
for (const file of input.attachments) form.append('attachments', file);
|
||||
return form;
|
||||
}
|
||||
|
||||
/**
|
||||
* `apiClient` pins `Content-Type: application/json` on every request. Axios
|
||||
* reads that header in `transformRequest` and, seeing JSON, runs FormData
|
||||
* through `formDataToJSON` — the files would be silently dropped and the server
|
||||
* would store an empty message. Clearing the header (rather than setting
|
||||
* `multipart/form-data` by hand, which omits the boundary) is what lets the
|
||||
* browser generate a proper boundary of its own.
|
||||
*/
|
||||
const multipartConfig = { headers: { 'Content-Type': undefined } };
|
||||
|
||||
/** Passenger backoffice (agent) support-chat REST calls (client unwraps envelope). */
|
||||
export const supportApi = {
|
||||
listConversations: (params: ListParams = {}) =>
|
||||
apiClient.get<ListResult>('/support/agent/conversations', { params }),
|
||||
listMessages: (id: string) =>
|
||||
apiClient.get<MessageDto[]>(`/support/agent/conversations/${id}/messages`),
|
||||
sendMessage: (id: string, text: string) =>
|
||||
apiClient.post<MessageDto>(
|
||||
listMessages: (id: string, params: ListMessagesParams = {}) =>
|
||||
apiClient.get<MessageListResult>(`/support/agent/conversations/${id}/messages`, { params }),
|
||||
sendMessage: (id: string, input: SendMessageInput) => {
|
||||
const body = toRequestBody(input);
|
||||
return apiClient.post<MessageDto>(
|
||||
`/support/agent/conversations/${id}/messages`,
|
||||
{ text },
|
||||
),
|
||||
body,
|
||||
body instanceof FormData ? multipartConfig : undefined,
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through this client so the bearer token rides along
|
||||
* — the guard reads `Authorization` only, so a direct `<img src>` would 401.
|
||||
*
|
||||
* `relativeUrl` is the DTO's `url` (`/support/attachments/:id`); the passenger
|
||||
* API has no global prefix, so it appends to the client's baseURL as-is.
|
||||
*/
|
||||
fetchAttachment: async (relativeUrl: string): Promise<Blob> => {
|
||||
const data = await apiClient.getRaw<Blob>(relativeUrl, { responseType: 'blob' });
|
||||
return data;
|
||||
},
|
||||
setStatus: (id: string, status: string) =>
|
||||
apiClient.patch<ConversationDto>(
|
||||
`/support/agent/conversations/${id}/status`,
|
||||
{ status },
|
||||
),
|
||||
apiClient.patch<ConversationDto>(`/support/agent/conversations/${id}/status`, { status }),
|
||||
markRead: (id: string) =>
|
||||
apiClient.post<{ unreadCount: number }>(
|
||||
`/support/agent/conversations/${id}/read`,
|
||||
),
|
||||
unreadCount: () =>
|
||||
apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
|
||||
apiClient.post<{ unreadCount: number }>(`/support/agent/conversations/${id}/read`),
|
||||
unreadCount: () => apiClient.get<{ unreadCount: number }>('/support/agent/unread-count'),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from '@edr/types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** A file staged in the composer, not yet sent. */
|
||||
export interface PendingAttachment {
|
||||
/** Local-only id; the server id doesn't exist until the message is sent. */
|
||||
id: string;
|
||||
file: File;
|
||||
/** Object URL, images only. Revoked when the entry goes away. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* Staging area for files being attached to a message.
|
||||
*
|
||||
* Files are held client-side until send, then posted alongside the text in one
|
||||
* multipart request — there's no upload-then-reference step, so nothing to
|
||||
* garbage-collect if the agent changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, pasting screenshots into a long-lived chat page leaks the full bytes of
|
||||
* every image for the life of the tab.
|
||||
*/
|
||||
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const rejectRef = useRef(onReject);
|
||||
rejectRef.current = onReject;
|
||||
|
||||
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
||||
// still-live URLs) on every change to the list.
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const a of attachmentsRef.current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const add = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setAttachments((current) => {
|
||||
const accepted: PendingAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (current.length + accepted.length >= SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
rejectRef.current?.(`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`);
|
||||
break;
|
||||
}
|
||||
if (!isSupportAttachmentAllowed(file.type)) {
|
||||
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
rejectRef.current?.(
|
||||
`${file.name} is over the ${SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)}MB limit.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: `pending-${nextId++}`,
|
||||
file,
|
||||
previewUrl: isSupportAttachmentImage(file.type) ? URL.createObjectURL(file) : undefined,
|
||||
});
|
||||
}
|
||||
return accepted.length ? [...current, ...accepted] : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachments((current) => {
|
||||
const target = current.find((a) => a.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return current.filter((a) => a.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachments((current) => {
|
||||
for (const a of current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pull files off a paste. Returns true if anything was taken, so the caller
|
||||
* can suppress the default paste — otherwise pasting a screenshot also drops
|
||||
* its filename (or nothing) into the textarea.
|
||||
*
|
||||
* Copying an image in most apps puts BOTH the bitmap and some text/html on
|
||||
* the clipboard, so check for files first and only then let the text through.
|
||||
*/
|
||||
const addFromPaste = useCallback(
|
||||
(clipboard: DataTransfer | null): boolean => {
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
add(files);
|
||||
return true;
|
||||
},
|
||||
[add],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
files: attachments.map((a) => a.file),
|
||||
add,
|
||||
addFromPaste,
|
||||
remove,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { supportApi } from './supportApi';
|
||||
|
||||
/**
|
||||
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
|
||||
*
|
||||
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
|
||||
* guard takes the bearer token from the `Authorization` header and has no cookie
|
||||
* fallback, and an `<img>` request cannot carry that header — a direct src is an
|
||||
* unavoidable 401. So the bytes are fetched through the authenticated client and
|
||||
* handed to the browser as an object URL.
|
||||
*
|
||||
* The URL is revoked on unmount and whenever the attachment changes, so a thread
|
||||
* scrolled through hundreds of images doesn't pin all of them in memory.
|
||||
*/
|
||||
export function useAttachmentObjectUrl(relativeUrl: string): {
|
||||
src?: string;
|
||||
failed: boolean;
|
||||
} {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let created: string | undefined;
|
||||
|
||||
setSrc(undefined);
|
||||
setFailed(false);
|
||||
|
||||
supportApi
|
||||
.fetchAttachment(relativeUrl)
|
||||
.then((blob) => {
|
||||
// The component may have unmounted mid-flight; creating a URL then would
|
||||
// leak it, since the cleanup below has already run.
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setSrc(created);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [relativeUrl]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand variant for files that aren't previewed inline (documents): fetch
|
||||
* only when the user actually opens one, rather than pulling every attachment in
|
||||
* the thread down just to render a filename row.
|
||||
*
|
||||
* Holds a single slot — opening another file revokes the previous URL, as does
|
||||
* unmounting.
|
||||
*/
|
||||
export function useLazyAttachmentObjectUrl(): (relativeUrl: string) => Promise<string> {
|
||||
const current = useRef<string>();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useCallback(async (relativeUrl: string) => {
|
||||
const blob = await supportApi.fetchAttachment(relativeUrl);
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
current.current = URL.createObjectURL(blob);
|
||||
return current.current;
|
||||
}, []);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage } from '@edr/types';
|
||||
import { Download, ExternalLink, FileText } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import Modal from '@/components/ui/Modal';
|
||||
|
||||
/** The minimal file shape the preview needs. */
|
||||
export interface PreviewableFile {
|
||||
name: string;
|
||||
/**
|
||||
* A blob object URL, not the attachment's API path — the API guard reads the
|
||||
* bearer token from the `Authorization` header, which `<img>` and `<a>` can't
|
||||
* send. Callers fetch the bytes first (see `useAttachmentObjectUrl`).
|
||||
*/
|
||||
url: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives a single shared preview modal for the page: call `view(file)` from any
|
||||
* attachment, render `viewer` once near the page root.
|
||||
*
|
||||
* Mirrors the shape of `useFileViewer` from `@edr/ui-common`, which the freight
|
||||
* backoffice uses. That hook can't be used here: it renders `@mantine/core`
|
||||
* components, and this app is Tailwind-only with no `MantineProvider` mounted —
|
||||
* its Modal would throw at runtime. Kept deliberately narrow (images inline,
|
||||
* everything else handed to the browser) rather than reimplementing the shared
|
||||
* viewer's pdf/office/video handling; swap this for the shared hook if this app
|
||||
* ever adopts Mantine.
|
||||
*/
|
||||
export function useFilePreview() {
|
||||
const [file, setFile] = useState<PreviewableFile | null>(null);
|
||||
|
||||
const view = useCallback((f: PreviewableFile) => setFile(f), []);
|
||||
const close = useCallback(() => setFile(null), []);
|
||||
|
||||
const viewer = (
|
||||
<Modal isOpen={file !== null} onClose={close} title={file?.name ?? ''} size="xl">
|
||||
{file && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{isSupportAttachmentImage(file.mimeType) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="max-h-[70vh] w-auto max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-10 text-muted-foreground">
|
||||
<FileText size={48} />
|
||||
<p className="text-sm">No inline preview for this file type.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-foreground transition hover:bg-muted"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
Open in new tab
|
||||
</a>
|
||||
<a
|
||||
href={file.url}
|
||||
download={file.name}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium text-white transition hover:opacity-90"
|
||||
style={{ background: 'rgb(20 113 76)' }}
|
||||
>
|
||||
<Download size={14} />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return { view, close, viewer };
|
||||
}
|
||||
@@ -1,26 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Passenger } from '@edr/types';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { supportApi, type ListParams } from './supportApi';
|
||||
import { supportApi, type ListParams, type SendMessageInput } from './supportApi';
|
||||
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type MessageListResult = Passenger.PassengerSupportMessageListResult;
|
||||
|
||||
export const SUPPORT_CONVERSATIONS_KEY = ['support', 'conversations'];
|
||||
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
||||
export const supportMessagesKey = (id: string) => ['support', 'messages', id];
|
||||
|
||||
/** Threads per page in the inbox. */
|
||||
const CONVERSATIONS_PAGE_SIZE = 20;
|
||||
/** Messages per page in a thread. */
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Shared inbox: every thread, filterable by status + subject/passenger search.
|
||||
*
|
||||
* Pages on scroll. This previously fetched one unbounded page and rendered
|
||||
* whatever came back, which silently truncated the inbox at the server's own
|
||||
* default of 100 with no way to reach the rest.
|
||||
*/
|
||||
export function useConversations(params: ListParams = {}) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: [...SUPPORT_CONVERSATIONS_KEY, params],
|
||||
queryFn: () => supportApi.listConversations(params),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listConversations({
|
||||
...params,
|
||||
page: pageParam,
|
||||
limit: CONVERSATIONS_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const loaded = allPages.reduce((n, page) => n + page.items.length, 0);
|
||||
return loaded < lastPage.count ? allPages.length + 1 : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Flatten for rendering, keeping the page-level fields (count/unreadCount)
|
||||
* from the first fetch so badges don't go stale as more pages load.
|
||||
*
|
||||
* De-duplicated by id because this list pages by OFFSET over a sort key that
|
||||
* moves: a thread jumps to rank 1 the moment it gets a message, shifting
|
||||
* everything down, so a row already shown on page 1 can be served again on
|
||||
* page 2 — and React would then see two children with the same key. The
|
||||
* conversations invalidate that rides along with every such event heals the
|
||||
* ordering a beat later; this just stops the intervening render from breaking.
|
||||
*
|
||||
* Keyset wouldn't help here, unlike the message list: the sort key itself
|
||||
* mutates, so no cursor over it is stable either.
|
||||
*/
|
||||
const items = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const flat: Passenger.PassengerSupportConversationDto[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const conversation of page.items) {
|
||||
if (seen.has(conversation.id)) continue;
|
||||
seen.add(conversation.id);
|
||||
flat.push(conversation);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
items,
|
||||
count: query.data?.pages[0]?.count ?? 0,
|
||||
unreadCount: query.data?.pages[0]?.unreadCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A thread's messages, paged backwards from newest.
|
||||
*
|
||||
* react-query's "next page" is *older* history here, so `pages` runs
|
||||
* newest-block-first and has to be reversed to render top-to-bottom in time
|
||||
* order. Cursor-based rather than offset so a message arriving mid-scroll
|
||||
* doesn't shift the pages already loaded.
|
||||
*/
|
||||
export function useMessages(conversationId: string | null) {
|
||||
return useQuery({
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: supportMessagesKey(conversationId ?? ''),
|
||||
queryFn: () => supportApi.listMessages(conversationId as string),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.listMessages(conversationId as string, {
|
||||
before: pageParam,
|
||||
limit: MESSAGES_PAGE_SIZE,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled: !!conversationId,
|
||||
});
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.items),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// Exposed so the view can tell a prepended history page from a message
|
||||
// appended at the bottom — `messages` changing says nothing about which.
|
||||
return { ...query, messages, pageCount: query.data?.pages.length ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into a cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: that refetches *every* page the agent
|
||||
* has scrolled back through, so the cost of each inbound message would grow
|
||||
* with how far they've read. Page 0 is the newest block and its items are
|
||||
* oldest-first within the block, so the new message belongs on its end.
|
||||
*
|
||||
* No-ops when the thread isn't cached — nothing is rendering it, and seeding a
|
||||
* partial cache here would leave a thread whose "first page" is one message and
|
||||
* whose `nextCursor` is missing.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than a boolean: the two ways it can decline
|
||||
* to append need opposite handling. A duplicate is the sender's own echo and must
|
||||
* be ignored — refetching there would undo the whole point. "Uncached" means the
|
||||
* thread's first page is still in flight and may have been read on the server
|
||||
* *before* this message existed, so dropping it silently would lose it until
|
||||
* something else happened to refetch; the caller refetches instead. That's cheap
|
||||
* precisely because nothing is loaded yet.
|
||||
*/
|
||||
export type AppendOutcome = 'appended' | 'duplicate' | 'uncached';
|
||||
|
||||
export function appendMessageToCache(qc: QueryClient, message: MessageDto): AppendOutcome {
|
||||
let outcome: AppendOutcome = 'uncached';
|
||||
qc.setQueryData<InfiniteData<MessageListResult>>(
|
||||
supportMessagesKey(message.conversationId),
|
||||
(current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
if (newest.items.some((m) => m.id === message.id)) {
|
||||
outcome = 'duplicate';
|
||||
return current;
|
||||
}
|
||||
outcome = 'appended';
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, items: [...newest.items, message] }, ...rest],
|
||||
};
|
||||
},
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
@@ -35,9 +170,12 @@ export function useUnreadCount(enabled = true) {
|
||||
export function useSendMessage(conversationId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (text: string) => supportApi.sendMessage(conversationId, text),
|
||||
mutationFn: (input: SendMessageInput) => supportApi.sendMessage(conversationId, input),
|
||||
// The gateway echoes our own message back over the socket, which appends it
|
||||
// to the cache — so don't invalidate the thread here or every send would
|
||||
// refetch every page the agent has scrolled through. The conversation list
|
||||
// still needs a refresh for its last-message preview and ordering.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: supportMessagesKey(conversationId) });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
},
|
||||
});
|
||||
@@ -48,8 +186,7 @@ export function useSetStatus() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
supportApi.setStatus(id, status),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ import { useEffect, useRef } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import {
|
||||
appendMessageToCache,
|
||||
SUPPORT_CONVERSATIONS_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
supportMessagesKey,
|
||||
} from './useSupport';
|
||||
|
||||
const SOCKET_ORIGIN = String(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
).replace(/\/api\/?$/, '');
|
||||
const SOCKET_ORIGIN = String(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000').replace(
|
||||
/\/api\/?$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribes the signed-in agent to live support pushes for the whole shared
|
||||
@@ -33,37 +35,39 @@ export function useSupportSocket(
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(
|
||||
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
||||
{
|
||||
const socket = io(`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Temporary diagnostics — remove once live delivery is confirmed.
|
||||
socket.on('connect', () =>
|
||||
console.warn('[support] agent socket connected', socket.id),
|
||||
);
|
||||
socket.on('connect', () => console.warn('[support] agent socket connected', socket.id));
|
||||
socket.on('connect_error', (err) =>
|
||||
console.warn('[support] agent socket connect_error:', err.message),
|
||||
);
|
||||
socket.on('disconnect', (reason) =>
|
||||
console.warn('[support] agent socket disconnected:', reason),
|
||||
);
|
||||
socket.on('support:hello', (info) =>
|
||||
console.warn('[support] server assigned:', info),
|
||||
);
|
||||
socket.on('support:hello', (info) => console.warn('[support] server assigned:', info));
|
||||
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated, and
|
||||
// invalidating it would refetch every page the agent has scrolled back
|
||||
// through on every single inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === 'uncached') {
|
||||
// The thread's first page is still loading and may have been read
|
||||
// before this message existed — without this it would go missing
|
||||
// until some unrelated refetch. Cheap: there are no pages to
|
||||
// re-fetch yet.
|
||||
qc.invalidateQueries({
|
||||
queryKey: supportMessagesKey(event.message.conversationId),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_CONVERSATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
onMessageRef.current?.(event);
|
||||
|
||||
@@ -31,7 +31,7 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,15 @@ class ApiClient {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET without the `{ success, data }` unwrap. Binary endpoints (file streams)
|
||||
* have no envelope to unwrap, so `get` would hand back `undefined`.
|
||||
*/
|
||||
async getRaw<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.get<T>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response = await this.client.post<{ success: boolean; data: T }>(url, data, config);
|
||||
return response.data.data;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { FileText, X } from 'lucide-react';
|
||||
|
||||
import { formatBytes } from './MessageAttachments';
|
||||
import type { PendingAttachment } from './useAttachmentDraft';
|
||||
|
||||
/**
|
||||
* The staged-files strip above the composer. Shows what will be sent and lets
|
||||
* the visitor drop any of it before hitting send.
|
||||
*/
|
||||
export function AttachmentDraftBar({
|
||||
attachments,
|
||||
onRemove,
|
||||
}: {
|
||||
attachments: PendingAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="relative flex items-center gap-1.5 rounded-lg border border-gray-200 p-1 pr-4 dark:border-slate-600"
|
||||
>
|
||||
{a.previewUrl ? (
|
||||
// next/image can't take a `blob:` object URL — there's nothing at a
|
||||
// routable origin for the optimizer to fetch.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={a.previewUrl}
|
||||
alt={a.file.name}
|
||||
className="h-9 w-9 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-300">
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 max-w-[120px]">
|
||||
<span className="block truncate text-xs font-semibold text-gray-700 dark:text-slate-200">
|
||||
{a.file.name}
|
||||
</span>
|
||||
<span className="block text-[10px] text-gray-400">{formatBytes(a.file.size)}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onRemove(a.id)}
|
||||
aria-label={`Remove ${a.file.name}`}
|
||||
className="absolute -right-1.5 -top-1.5 grid h-4 w-4 place-items-center rounded-full bg-gray-600 text-white transition hover:bg-gray-800"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/**
|
||||
* Full-bleed preview for an image attachment.
|
||||
*
|
||||
* Deliberately local and hand-rolled rather than `@edr/ui-common`'s FileViewer:
|
||||
* that component is Mantine-based and this app mounts no MantineProvider, so
|
||||
* importing it would throw at runtime. All we need here is a backdrop and an
|
||||
* `<img>`.
|
||||
*
|
||||
* Portalled to `document.body` because its call site is nested inside the chat
|
||||
* panel, which is `overflow-hidden` and lives in the launcher's `z-30` stacking
|
||||
* context. Rendered in place, the z-[120] below would be trapped in that context
|
||||
* and mean nothing; portalled, it genuinely reaches the modal tier and paints
|
||||
* over the chat that opened it.
|
||||
*
|
||||
* Only ever rendered for image mime types: `<img>` cannot execute its source,
|
||||
* whereas navigating to or framing an attachment could. SVG is excluded from the
|
||||
* allowed upload types upstream for exactly this reason.
|
||||
*/
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={alt}
|
||||
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/80 p-6"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close preview"
|
||||
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition hover:bg-white/20"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
{/* Stop the backdrop's close handler firing when the image itself is
|
||||
clicked — panning a zoomed screenshot shouldn't dismiss it. */}
|
||||
{/* Plain <img>: `src` is a blob: object URL fetched through the
|
||||
authenticated client, which next/image cannot optimize. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default ImagePreview;
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { isSupportAttachmentImage, type Passenger } from '@edr/types';
|
||||
import { FileText, ImageOff } from 'lucide-react';
|
||||
|
||||
import { useAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One image attachment. Its own component because the bytes are fetched through
|
||||
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
||||
* be called from inside a `.map()`.
|
||||
*/
|
||||
function ImageAttachment({
|
||||
a,
|
||||
onView,
|
||||
}: {
|
||||
a: AttachmentDto;
|
||||
onView: (a: AttachmentDto, src: string) => void;
|
||||
}) {
|
||||
const { src, failed } = useAttachmentObjectUrl(a.url);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs opacity-75">
|
||||
<ImageOff size={14} className="shrink-0" />
|
||||
Couldn't load {a.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="h-[120px] w-[200px] animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onView(a, src)}
|
||||
className="block overflow-hidden rounded-lg"
|
||||
aria-label={`View ${a.name}`}
|
||||
>
|
||||
{/* Capped: a tall screenshot would otherwise push the whole conversation
|
||||
off-screen. Plain <img>: the source is a blob: object URL, which
|
||||
next/image cannot optimize. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={a.name}
|
||||
className="max-h-[220px] max-w-[240px] cursor-zoom-in rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachments inside a message bubble: images as thumbnails, everything else as
|
||||
* a labelled file row. Non-images are not fetched until opened — pulling every
|
||||
* document in a thread just to draw a filename would be wasteful.
|
||||
*/
|
||||
export function MessageAttachments({
|
||||
attachments,
|
||||
mine,
|
||||
onViewImage,
|
||||
onOpenFile,
|
||||
}: {
|
||||
attachments: AttachmentDto[];
|
||||
mine: boolean;
|
||||
onViewImage: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
{attachments.map((a) =>
|
||||
isSupportAttachmentImage(a.mimeType) ? (
|
||||
<ImageAttachment key={a.id} a={a} onView={onViewImage} />
|
||||
) : (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onOpenFile(a)}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left transition ${
|
||||
mine
|
||||
? 'bg-white/20 hover:bg-white/30'
|
||||
: 'border border-gray-200 bg-white hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<FileText size={16} className="shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">{a.name}</span>
|
||||
<span className="block text-[10px] opacity-75">{formatBytes(a.size)}</span>
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { Passenger } from '@edr/types';
|
||||
import { Headset, Send, X } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Passenger, SUPPORT_ATTACHMENT_ACCEPT } from '@edr/types';
|
||||
import { Headset, Paperclip, Send, X } from 'lucide-react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AttachmentDraftBar } from './AttachmentDraftBar';
|
||||
import { ImagePreview } from './ImagePreview';
|
||||
import { MessageAttachments } from './MessageAttachments';
|
||||
import { useAttachmentDraft } from './useAttachmentDraft';
|
||||
import { useLazyAttachmentObjectUrl } from './useAttachmentObjectUrl';
|
||||
import { useMarkRead, useSendMessage, useThread } from './useSupport';
|
||||
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type AttachmentDto = Passenger.PassengerSupportAttachmentDto;
|
||||
|
||||
/** How close to an edge counts as "at" it, in px. */
|
||||
const SCROLL_EDGE_SLOP = 40;
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
@@ -19,29 +28,122 @@ function formatTime(iso?: string | null): string {
|
||||
}
|
||||
|
||||
export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
const { data, isLoading } = useThread();
|
||||
const { messages, pageCount, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useThread();
|
||||
const send = useSendMessage();
|
||||
const markRead = useMarkRead();
|
||||
const [draft, setDraft] = useState('');
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Holds the blob object URL the thumbnail already fetched, not the attachment
|
||||
// itself — the preview can't load from `a.url` on its own (see MessageAttachments).
|
||||
const [preview, setPreview] = useState<{ name: string; src: string } | null>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const attach = useAttachmentDraft(setError);
|
||||
const loadAttachment = useLazyAttachmentObjectUrl();
|
||||
|
||||
const messages = data?.messages ?? [];
|
||||
/**
|
||||
* Scroll height captured just before an older page was requested, tagged with
|
||||
* the page count at that moment.
|
||||
*
|
||||
* The page count is what makes this safe. Keyed on presence alone, a message
|
||||
* arriving over the socket while history was still in flight would consume the
|
||||
* snapshot on a one-bubble append, and the real 30-message prepend would then
|
||||
* land with nothing to correct against — throwing the visitor exactly as far as
|
||||
* this exists to prevent. Comparing counts means only an actual new page can
|
||||
* claim it.
|
||||
*/
|
||||
const pendingRestore = useRef<{ height: number; atPageCount: number } | null>(null);
|
||||
/** Whether the visitor is parked at the bottom and wants to follow new messages. */
|
||||
const stick = useRef(true);
|
||||
|
||||
const messageCount = messages.length;
|
||||
|
||||
// Mark read on open + whenever new messages arrive.
|
||||
useEffect(() => {
|
||||
markRead.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
}, [messageCount]);
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [messages.length]);
|
||||
/**
|
||||
* Keep the viewport sensible as the list changes underneath it.
|
||||
*
|
||||
* Two different things change `messages`, and they want opposite behaviour: a
|
||||
* new message at the bottom should follow (if the visitor is already there),
|
||||
* while an older page prepended at the top must NOT move the content they're
|
||||
* reading. Layout effect, not effect — this has to run before paint or the
|
||||
* prepend visibly jumps.
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
const restore = pendingRestore.current;
|
||||
if (restore && pageCount > restore.atPageCount) {
|
||||
// An older page went in above: push the scroll down by exactly the height
|
||||
// that was added, so the same message stays under the cursor.
|
||||
el.scrollTop += el.scrollHeight - restore.height;
|
||||
pendingRestore.current = null;
|
||||
return;
|
||||
}
|
||||
if (stick.current) el.scrollTo({ top: el.scrollHeight });
|
||||
}, [messages, pageCount]);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = viewport.current;
|
||||
if (!el) return;
|
||||
const y = el.scrollTop;
|
||||
stick.current = el.scrollHeight - y - el.clientHeight < SCROLL_EDGE_SLOP;
|
||||
if (y < SCROLL_EDGE_SLOP && hasNextPage && !isFetchingNextPage) {
|
||||
// A failed fetch leaves this set, which is harmless: the list didn't
|
||||
// change, so the height is still accurate for the retry, and the count
|
||||
// tag stops it being mistaken for a landed page in the meantime.
|
||||
pendingRestore.current = {
|
||||
height: el.scrollHeight,
|
||||
atPageCount: pageCount,
|
||||
};
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
// Images already hold their bytes as an object URL from rendering the
|
||||
// thumbnail, so reuse it rather than fetching the same file twice.
|
||||
const viewImage = (a: AttachmentDto, src: string) => setPreview({ name: a.name, src });
|
||||
|
||||
/**
|
||||
* Documents aren't fetched until opened. There's no inline viewer in this
|
||||
* widget, so the bytes are handed to the browser as a download: a
|
||||
* `window.open` after the await has lost its user-gesture and gets caught by
|
||||
* popup blockers, whereas a synthetic anchor click does not.
|
||||
*/
|
||||
const openFile = async (a: AttachmentDto) => {
|
||||
try {
|
||||
const src = await loadAttachment(a.url);
|
||||
const link = document.createElement('a');
|
||||
link.href = src;
|
||||
link.download = a.name;
|
||||
link.click();
|
||||
} catch {
|
||||
setError(`Couldn't open ${a.name}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
if (!text && attach.attachments.length === 0) return;
|
||||
const files = attach.files;
|
||||
// Clear optimistically so the composer feels instant; on failure the text is
|
||||
// restored below rather than silently lost.
|
||||
setDraft('');
|
||||
await send.mutateAsync(text);
|
||||
attach.clear();
|
||||
setError(null);
|
||||
stick.current = true;
|
||||
try {
|
||||
await send.mutateAsync({ text: text || undefined, attachments: files });
|
||||
} catch (e) {
|
||||
setDraft(text);
|
||||
setError(e instanceof Error ? e.message : "Couldn't send that message.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,7 +151,9 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3 text-white"
|
||||
style={{ background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))` }}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||
@@ -60,17 +164,13 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
<p className="text-xs text-white/80">We usually reply in a few minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-full p-1 hover:bg-white/20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<button onClick={onClose} className="rounded-full p-1 hover:bg-white/20" aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={viewport} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
<div ref={viewport} onScroll={onScroll} className="flex-1 space-y-3 overflow-y-auto p-4">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400">Loading…</div>
|
||||
) : messages.length === 0 ? (
|
||||
@@ -81,20 +181,76 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
<Headset size={24} />
|
||||
</span>
|
||||
Hi! 👋 How can we help you today? Send us a message and our team will
|
||||
get back to you.
|
||||
Hi! 👋 How can we help you today? Send us a message and our team will get back to you.
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => <MessageBubble key={m.id} m={m} />)
|
||||
<>
|
||||
{isFetchingNextPage && (
|
||||
<p className="py-1 text-center text-xs text-gray-400">Loading earlier messages…</p>
|
||||
)}
|
||||
{!hasNextPage && (
|
||||
<p className="text-center text-[10px] text-gray-400">Start of conversation</p>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<MessageBubble key={m.id} m={m} onViewImage={viewImage} onOpenFile={openFile} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="border-t border-gray-100 p-3 dark:border-slate-700">
|
||||
<div
|
||||
className={`border-t p-3 ${
|
||||
dragging
|
||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/30'
|
||||
: 'border-gray-100 dark:border-slate-700'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
attach.add(Array.from(e.dataTransfer.files));
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<p className="mb-2 text-xs text-red-600 dark:text-red-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<AttachmentDraftBar attachments={attach.attachments} onRemove={attach.remove} />
|
||||
<div className="flex items-end gap-2">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
attach.add(Array.from(e.currentTarget.files ?? []));
|
||||
// Reset so picking the same file twice in a row still fires change.
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label="Attach files"
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
>
|
||||
<Paperclip size={18} />
|
||||
</button>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
// Screenshots land on the clipboard as files. Take them and suppress
|
||||
// the default, which would otherwise also paste the image's name (or
|
||||
// nothing) as text.
|
||||
onPaste={(e) => {
|
||||
if (attach.addFromPaste(e.clipboardData)) e.preventDefault();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
@@ -102,12 +258,12 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder="Type a message…"
|
||||
placeholder="Type a message, or paste an image…"
|
||||
className="max-h-24 flex-1 resize-none rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-emerald-500 dark:border-slate-600 dark:bg-slate-800 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
disabled={(!draft.trim() && attach.attachments.length === 0) || send.isPending}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-white transition hover:opacity-90 disabled:opacity-50"
|
||||
style={{ background: GREEN }}
|
||||
aria-label="Send"
|
||||
@@ -116,19 +272,29 @@ export function SupportPanel({ onClose }: { onClose: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<ImagePreview src={preview.src} alt={preview.name} onClose={() => setPreview(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ m }: { m: MessageDto }) {
|
||||
function MessageBubble({
|
||||
m,
|
||||
onViewImage,
|
||||
onOpenFile,
|
||||
}: {
|
||||
m: MessageDto;
|
||||
onViewImage: (a: AttachmentDto, src: string) => void;
|
||||
onOpenFile: (a: AttachmentDto) => void;
|
||||
}) {
|
||||
const mine = m.sender === 'USER';
|
||||
return (
|
||||
<div className={`flex ${mine ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className="max-w-[78%]">
|
||||
{!mine && (
|
||||
<p className="mb-0.5 ml-1 text-xs text-gray-400">
|
||||
{m.authorName || 'Support agent'}
|
||||
</p>
|
||||
<p className="mb-0.5 ml-1 text-xs text-gray-400">{m.authorName || 'Support agent'}</p>
|
||||
)}
|
||||
<div
|
||||
className={`whitespace-pre-wrap break-words rounded-2xl px-3 py-2 text-sm ${
|
||||
@@ -138,13 +304,17 @@ function MessageBubble({ m }: { m: MessageDto }) {
|
||||
}`}
|
||||
style={mine ? { background: GREEN } : undefined}
|
||||
>
|
||||
{m.text}
|
||||
{/* Attachment-only messages carry text: "" — rendering it anyway would
|
||||
leave an empty line above the thumbnail. */}
|
||||
{m.text && <p>{m.text}</p>}
|
||||
<MessageAttachments
|
||||
attachments={m.attachments}
|
||||
mine={mine}
|
||||
onViewImage={onViewImage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-0.5 text-[10px] text-gray-400 ${
|
||||
mine ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
<p className={`mt-0.5 text-[10px] text-gray-400 ${mine ? 'text-right' : 'text-left'}`}>
|
||||
{formatTime(m.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { Headset } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Headset } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { SupportPanel } from './SupportPanel';
|
||||
import { useUnreadCount } from './useSupport';
|
||||
import { useSupportSocket } from './useSupportSocket';
|
||||
import { SupportPanel } from "./SupportPanel";
|
||||
import { useUnreadCount } from "./useSupport";
|
||||
import { useSupportSocket } from "./useSupportSocket";
|
||||
|
||||
const GREEN = 'rgb(20 113 76)';
|
||||
const GREEN = "rgb(20 113 76)";
|
||||
const GRADIENT = `linear-gradient(135deg, ${GREEN}, rgb(30 140 96))`;
|
||||
|
||||
/**
|
||||
@@ -27,14 +27,17 @@ export function SupportWidget() {
|
||||
// bottom action bars (z-40) used on booking flow pages, so the floating launcher never
|
||||
// paints over dialog content or a page's "Continue" bar — it only sits above ordinary
|
||||
// in-page content.
|
||||
<div className="fixed bottom-6 right-6 z-30 flex flex-col items-end gap-3">
|
||||
<div className="fixed bottom-6 right-6 z-40 flex flex-col items-end gap-3">
|
||||
{open && <SupportPanel onClose={() => setOpen(false)} />}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open support chat"
|
||||
className="group relative flex items-center gap-2.5 rounded-full p-2 pr-2 text-white transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-primary active:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:pr-5"
|
||||
style={{ background: GRADIENT, boxShadow: '0 10px 28px rgba(20,113,76,0.4)' }}
|
||||
style={{
|
||||
background: GRADIENT,
|
||||
boxShadow: "0 10px 28px rgba(20,113,76,0.4)",
|
||||
}}
|
||||
>
|
||||
{/* Faint breathing ring; the unread badge is loud enough on its own. */}
|
||||
{unread === 0 && (
|
||||
@@ -56,7 +59,7 @@ export function SupportWidget() {
|
||||
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full border-2 border-white bg-red-500 px-1 text-xs font-bold text-white">
|
||||
{unread > 9 ? '9+' : unread}
|
||||
{unread > 9 ? "9+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -6,20 +6,81 @@ import { getDeviceId } from './deviceIdentity';
|
||||
type ThreadDto = Passenger.PassengerSupportThreadDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
|
||||
/** Messages per page when walking the thread backwards. */
|
||||
export const MESSAGES_PAGE_SIZE = 30;
|
||||
|
||||
export interface GetThreadParams {
|
||||
/** Opaque cursor from the previous page's `nextCursor`; omit for the newest page. */
|
||||
before?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** What the composer hands over: text, files, or both (never neither). */
|
||||
export interface SendMessageInput {
|
||||
text?: string;
|
||||
attachments?: File[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A message with files goes as multipart so the server persists them against the
|
||||
* message it creates in the same request; text-only stays JSON.
|
||||
*
|
||||
* The `Content-Type: undefined` override is load-bearing, not cargo cult.
|
||||
* `apiClient` pins `application/json` as an instance default, and axios's
|
||||
* request transform reads that header to decide what to do with a FormData body:
|
||||
* when it sees JSON it runs the form through `formDataToJSON` and posts *that*,
|
||||
* so the files would be silently dropped and the server would see an empty
|
||||
* message. Clearing the header lets axios's XHR adapter set `multipart/form-data`
|
||||
* itself, with the boundary — which is also why we never write that value by
|
||||
* hand: a hand-set Content-Type has no boundary and fails to parse.
|
||||
*/
|
||||
function toRequestBody(input: SendMessageInput): {
|
||||
data: FormData | { deviceId: string; text?: string };
|
||||
config?: { headers: Record<string, undefined> };
|
||||
} {
|
||||
const deviceId = getDeviceId();
|
||||
if (!input.attachments?.length) {
|
||||
return { data: { deviceId, text: input.text } };
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('deviceId', deviceId);
|
||||
// Attachment-only messages send an empty string rather than omitting the
|
||||
// field — the DTO models text as "" for those, not as absent.
|
||||
form.append('text', input.text ?? '');
|
||||
for (const file of input.attachments) form.append('attachments', file);
|
||||
return { data: form, config: { headers: { 'Content-Type': undefined } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Passenger portal support-chat calls — a single device-scoped thread. No auth,
|
||||
* no forms; everything is keyed by a localStorage device id.
|
||||
*/
|
||||
export const supportApi = {
|
||||
getThread: (): Promise<ThreadDto> =>
|
||||
/**
|
||||
* The device's thread. Returns the *newest page* of messages plus a cursor —
|
||||
* not the whole conversation; page backwards with `before`.
|
||||
*/
|
||||
getThread: (params: GetThreadParams = {}): Promise<ThreadDto> =>
|
||||
apiClient.get('/support/device/thread', {
|
||||
params: { deviceId: getDeviceId() },
|
||||
}),
|
||||
sendMessage: (text: string): Promise<MessageDto> =>
|
||||
apiClient.post('/support/device/messages', {
|
||||
deviceId: getDeviceId(),
|
||||
text,
|
||||
params: { deviceId: getDeviceId(), ...params },
|
||||
}),
|
||||
sendMessage: (input: SendMessageInput): Promise<MessageDto> => {
|
||||
const { data, config } = toRequestBody(input);
|
||||
return apiClient.post('/support/device/messages', data, config);
|
||||
},
|
||||
/**
|
||||
* Attachment bytes, fetched through this client rather than linked directly:
|
||||
* the API guard reads the bearer token from the `Authorization` header with no
|
||||
* cookie fallback, and an `<img src>` can't carry that header — it would 401.
|
||||
*
|
||||
* `relativeUrl` is the DTO's `url` (`/support/attachments/:id`); the passenger
|
||||
* API has no global prefix, so it appends to the client's baseURL as-is.
|
||||
* `apiClient.get` unwraps a `{ data }` envelope when it finds one; a Blob has
|
||||
* no such property, so the body comes back untouched.
|
||||
*/
|
||||
fetchAttachment: (relativeUrl: string): Promise<Blob> =>
|
||||
apiClient.get<Blob>(relativeUrl, { responseType: 'blob' }),
|
||||
markRead: (): Promise<{ unreadCount: number }> =>
|
||||
apiClient.post('/support/device/read', { deviceId: getDeviceId() }),
|
||||
unreadCount: (): Promise<{ unreadCount: number }> =>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
isSupportAttachmentAllowed,
|
||||
isSupportAttachmentImage,
|
||||
SUPPORT_ATTACHMENT_MAX_BYTES,
|
||||
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
|
||||
} from '@edr/types';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/** A file staged in the composer, not yet sent. */
|
||||
export interface PendingAttachment {
|
||||
/** Local-only id; the server id doesn't exist until the message is sent. */
|
||||
id: string;
|
||||
file: File;
|
||||
/** Object URL, images only. Revoked when the entry goes away. */
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/**
|
||||
* Staging area for files being attached to a message.
|
||||
*
|
||||
* Files are held client-side until send, then posted alongside the text in one
|
||||
* multipart request — there's no upload-then-reference step, so nothing to
|
||||
* garbage-collect if the visitor changes their mind.
|
||||
*
|
||||
* Object URLs for image previews are revoked on removal and unmount; without
|
||||
* that, pasting screenshots into a long-lived page leaks the full bytes of every
|
||||
* image for the life of the tab.
|
||||
*/
|
||||
export function useAttachmentDraft(onReject?: (reason: string) => void) {
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const rejectRef = useRef(onReject);
|
||||
rejectRef.current = onReject;
|
||||
|
||||
// Read from a ref in the unmount cleanup so it doesn't re-run (and revoke
|
||||
// still-live URLs) on every change to the list.
|
||||
const attachmentsRef = useRef(attachments);
|
||||
attachmentsRef.current = attachments;
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const a of attachmentsRef.current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const add = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setAttachments((current) => {
|
||||
const accepted: PendingAttachment[] = [];
|
||||
for (const file of files) {
|
||||
if (current.length + accepted.length >= SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
|
||||
rejectRef.current?.(`Up to ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`);
|
||||
break;
|
||||
}
|
||||
if (!isSupportAttachmentAllowed(file.type)) {
|
||||
rejectRef.current?.(`${file.name}: that file type isn't supported.`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
|
||||
rejectRef.current?.(
|
||||
`${file.name} is over the ${SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)}MB limit.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push({
|
||||
id: `pending-${nextId++}`,
|
||||
file,
|
||||
previewUrl: isSupportAttachmentImage(file.type) ? URL.createObjectURL(file) : undefined,
|
||||
});
|
||||
}
|
||||
return accepted.length ? [...current, ...accepted] : current;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachments((current) => {
|
||||
const target = current.find((a) => a.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
return current.filter((a) => a.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachments((current) => {
|
||||
for (const a of current) {
|
||||
if (a.previewUrl) URL.revokeObjectURL(a.previewUrl);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pull files off a paste. Returns true if anything was taken, so the caller
|
||||
* can suppress the default paste — otherwise pasting a screenshot also drops
|
||||
* its filename (or nothing) into the textarea.
|
||||
*
|
||||
* Copying an image in most apps puts BOTH the bitmap and some text/html on the
|
||||
* clipboard, so check for files first and only then let the text through.
|
||||
*/
|
||||
const addFromPaste = useCallback(
|
||||
(clipboard: DataTransfer | null): boolean => {
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if (files.length === 0) return false;
|
||||
add(files);
|
||||
return true;
|
||||
},
|
||||
[add],
|
||||
);
|
||||
|
||||
return {
|
||||
attachments,
|
||||
files: attachments.map((a) => a.file),
|
||||
add,
|
||||
addFromPaste,
|
||||
remove,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { supportApi } from './supportApi';
|
||||
|
||||
/**
|
||||
* Blob object URL for an attachment, or `undefined` while it loads / on failure.
|
||||
*
|
||||
* Chat attachments cannot be rendered with a direct `<img src={a.url}>`. The API
|
||||
* guard takes the bearer token from the `Authorization` header and has no cookie
|
||||
* fallback, and an `<img>` request cannot carry that header — a direct src is an
|
||||
* unavoidable 401. So the bytes are fetched through the authenticated client and
|
||||
* handed to the browser as an object URL.
|
||||
*
|
||||
* The URL is revoked on unmount and whenever the attachment changes, so a thread
|
||||
* scrolled through hundreds of images doesn't pin all of them in memory.
|
||||
*/
|
||||
export function useAttachmentObjectUrl(relativeUrl: string): {
|
||||
src?: string;
|
||||
failed: boolean;
|
||||
} {
|
||||
const [src, setSrc] = useState<string>();
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let created: string | undefined;
|
||||
|
||||
setSrc(undefined);
|
||||
setFailed(false);
|
||||
|
||||
supportApi
|
||||
.fetchAttachment(relativeUrl)
|
||||
.then((blob) => {
|
||||
// The component may have unmounted mid-flight; creating a URL then would
|
||||
// leak it, since the cleanup below has already run.
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setSrc(created);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [relativeUrl]);
|
||||
|
||||
return { src, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand variant for files that aren't previewed inline (documents): fetch
|
||||
* only when the user actually opens one, rather than pulling every attachment in
|
||||
* the thread down just to render a filename row.
|
||||
*
|
||||
* Holds a single slot — opening another file revokes the previous URL, as does
|
||||
* unmounting.
|
||||
*/
|
||||
export function useLazyAttachmentObjectUrl(): (relativeUrl: string) => Promise<string> {
|
||||
const current = useRef<string>();
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return useCallback(async (relativeUrl: string) => {
|
||||
const blob = await supportApi.fetchAttachment(relativeUrl);
|
||||
if (current.current) URL.revokeObjectURL(current.current);
|
||||
current.current = URL.createObjectURL(blob);
|
||||
return current.current;
|
||||
}, []);
|
||||
}
|
||||
@@ -1,19 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Passenger } from '@edr/types';
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
type UseInfiniteQueryResult,
|
||||
} from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { supportApi } from './supportApi';
|
||||
import { MESSAGES_PAGE_SIZE, supportApi, type SendMessageInput } from './supportApi';
|
||||
|
||||
type ThreadDto = Passenger.PassengerSupportThreadDto;
|
||||
type MessageDto = Passenger.PassengerSupportMessageDto;
|
||||
type ConversationDto = Passenger.PassengerSupportConversationDto;
|
||||
|
||||
export const SUPPORT_THREAD_KEY = ['support', 'thread'];
|
||||
export const SUPPORT_UNREAD_KEY = ['support', 'unread'];
|
||||
|
||||
/** The device's single support thread (conversation + messages). */
|
||||
export function useThread(enabled = true) {
|
||||
return useQuery({
|
||||
/**
|
||||
* The device's single support thread, paged backwards from newest.
|
||||
*
|
||||
* react-query's "next page" is *older* history here, so `pages` runs
|
||||
* newest-block-first and has to be reversed to render top-to-bottom in time
|
||||
* order. Cursor-based rather than offset so a message arriving mid-scroll can't
|
||||
* shift or duplicate the pages already loaded.
|
||||
*/
|
||||
// The return type is spelled out rather than inferred: spreading the query
|
||||
// result produces an anonymous type that names symbols from `query-core`, which
|
||||
// pnpm's non-flat node_modules makes unnameable from here (TS2742).
|
||||
export function useThread(enabled = true): UseInfiniteQueryResult<
|
||||
InfiniteData<ThreadDto>,
|
||||
Error
|
||||
> & {
|
||||
conversation: ConversationDto | null;
|
||||
messages: MessageDto[];
|
||||
pageCount: number;
|
||||
} {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: SUPPORT_THREAD_KEY,
|
||||
queryFn: () => supportApi.getThread(),
|
||||
queryFn: ({ pageParam }) =>
|
||||
supportApi.getThread({ before: pageParam, limit: MESSAGES_PAGE_SIZE }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Page 0 is the newest fetch, so its conversation carries the freshest status
|
||||
// and unread count even after older pages load.
|
||||
const conversation = query.data?.pages[0]?.conversation ?? null;
|
||||
|
||||
const messages = useMemo(
|
||||
() => [...(query.data?.pages ?? [])].reverse().flatMap((p) => p.messages),
|
||||
[query.data],
|
||||
);
|
||||
|
||||
// `pageCount` is exposed so the view can tell a prepended history page from a
|
||||
// message appended at the bottom — `messages` changing says nothing about which.
|
||||
return {
|
||||
...query,
|
||||
conversation,
|
||||
messages,
|
||||
pageCount: query.data?.pages.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a newly-arrived message into the cached thread.
|
||||
*
|
||||
* Deliberately not `invalidateQueries`: the thread is paginated, so refetching
|
||||
* would re-request *every* page the visitor has scrolled back through on every
|
||||
* single inbound message — the cost of a message would grow with how far they've
|
||||
* read. Page 0 is the newest block and its messages are oldest-first within the
|
||||
* block, so a new message belongs on its end.
|
||||
*
|
||||
* Doesn't seed the cache when the thread isn't loaded: a partial cache here would
|
||||
* leave a thread whose "first page" is one message and whose `nextCursor` is
|
||||
* missing — which would render as a complete conversation with no way to page
|
||||
* back. The caller is told instead, via the outcome below.
|
||||
*/
|
||||
/**
|
||||
* Why this reports an outcome rather than nothing: the two ways it can decline to
|
||||
* append need opposite handling. A duplicate is our own echo and must be ignored —
|
||||
* refetching there would undo the whole point. "Uncached" means the thread's first
|
||||
* page is still in flight and may have been read on the server *before* this
|
||||
* message existed, so dropping it silently would lose it until something else
|
||||
* happened to refetch; the caller refetches instead. That's cheap precisely
|
||||
* because nothing is loaded yet.
|
||||
*/
|
||||
export type AppendOutcome = 'appended' | 'duplicate' | 'uncached';
|
||||
|
||||
export function appendMessageToCache(qc: QueryClient, message: MessageDto): AppendOutcome {
|
||||
let outcome: AppendOutcome = 'uncached';
|
||||
qc.setQueryData<InfiniteData<ThreadDto>>(SUPPORT_THREAD_KEY, (current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
// Our own message arrives twice — once as the POST response, once as the
|
||||
// socket echo. Ignore the duplicate rather than render it twice.
|
||||
if (newest.messages.some((m) => m.id === message.id)) {
|
||||
outcome = 'duplicate';
|
||||
return current;
|
||||
}
|
||||
outcome = 'appended';
|
||||
return {
|
||||
...current,
|
||||
pages: [{ ...newest, messages: [...newest.messages, message] }, ...rest],
|
||||
};
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the cached conversation with a freshly-pushed copy.
|
||||
*
|
||||
* Page 0 is where `useThread` reads the conversation from, so patching it there
|
||||
* is enough; `messages` and `nextCursor` are left exactly as they were, which is
|
||||
* the point — this must not disturb the paged history. No-op when the thread
|
||||
* isn't cached: the in-flight fetch will bring the current conversation anyway.
|
||||
*/
|
||||
export function patchConversationInCache(qc: QueryClient, conversation: ConversationDto): void {
|
||||
qc.setQueryData<InfiniteData<ThreadDto>>(SUPPORT_THREAD_KEY, (current) => {
|
||||
if (!current?.pages.length) return current;
|
||||
const [newest, ...rest] = current.pages;
|
||||
return { ...current, pages: [{ ...newest, conversation }, ...rest] };
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
@@ -28,9 +141,11 @@ export function useUnreadCount(enabled = true) {
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (text: string) => supportApi.sendMessage(text),
|
||||
mutationFn: (input: SendMessageInput) => supportApi.sendMessage(input),
|
||||
// The gateway echoes our own message back over the socket, which appends it
|
||||
// to the cache — so don't invalidate the thread here, or every send would
|
||||
// refetch every page the visitor has scrolled through.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
@@ -40,8 +155,9 @@ export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => supportApi.markRead(),
|
||||
// Same reasoning as above: reading a thread changes only the badge, and the
|
||||
// messages already on screen are the ones being marked.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,11 +6,17 @@ import { useEffect } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import { getDeviceId } from './deviceIdentity';
|
||||
import { SUPPORT_THREAD_KEY, SUPPORT_UNREAD_KEY } from './useSupport';
|
||||
import {
|
||||
appendMessageToCache,
|
||||
patchConversationInCache,
|
||||
SUPPORT_THREAD_KEY,
|
||||
SUPPORT_UNREAD_KEY,
|
||||
} from './useSupport';
|
||||
|
||||
const SOCKET_ORIGIN = String(
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000',
|
||||
).replace(/\/api\/?$/, '');
|
||||
const SOCKET_ORIGIN = String(process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000').replace(
|
||||
/\/api\/?$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribes the device to live support pushes. The gateway joins a
|
||||
@@ -22,23 +28,42 @@ export function useSupportSocket(enabled: boolean) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof window === 'undefined') return;
|
||||
const socket = io(
|
||||
`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`,
|
||||
{
|
||||
const socket = io(`${SOCKET_ORIGIN}/${Passenger.PASSENGER_SUPPORT_WS_NAMESPACE}`, {
|
||||
auth: { guestId: getDeviceId() },
|
||||
// Prefer WebSocket, fall back to HTTP long-polling if the proxy blocks
|
||||
// the upgrade (polling rides normal HTTPS, already CSP-allowed).
|
||||
transports: ['websocket', 'polling'],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW,
|
||||
(event: Passenger.PassengerSupportMessageEvent) => {
|
||||
// Append rather than invalidate: the thread is paginated now, and
|
||||
// invalidating would refetch every loaded page on every inbound message.
|
||||
if (appendMessageToCache(qc, event.message) === 'uncached') {
|
||||
// The thread's first page is still loading and may have been read on
|
||||
// the server before this message existed — without this it would go
|
||||
// missing until some unrelated refetch. Cheap: no pages loaded yet.
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
},
|
||||
);
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_THREAD_KEY });
|
||||
// Patch the conversation in place rather than invalidating the thread key —
|
||||
// the same key the paginated history uses. The server emits this alongside
|
||||
// MESSAGE_NEW for *every* message, so invalidating here would refetch every
|
||||
// page the visitor has scrolled back through on each one, cancelling out the
|
||||
// append above entirely. The payload is the updated conversation, so there's
|
||||
// nothing to go back to the server for.
|
||||
socket.on(
|
||||
Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED,
|
||||
(conversation: Passenger.PassengerSupportConversationDto) => {
|
||||
patchConversationInCache(qc, conversation);
|
||||
qc.invalidateQueries({ queryKey: SUPPORT_UNREAD_KEY });
|
||||
};
|
||||
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.MESSAGE_NEW, refresh);
|
||||
socket.on(Passenger.PASSENGER_SUPPORT_WS_EVENTS.CONVERSATION_UPDATED, refresh);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./payments";
|
||||
export * from "./payment-messaging";
|
||||
export * from "./support-attachments";
|
||||
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
|
||||
73
packages/types/src/common/support-attachments.ts
Normal file
73
packages/types/src/common/support-attachments.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Rules shared by the freight and passenger support-chat attachment flows.
|
||||
*
|
||||
* The two chat backends are independent implementations (freight: TypeORM +
|
||||
* polymorphic `FileRecord`; passenger: Prisma + `SupportAttachment`), but the
|
||||
* *contract* a client codes against — what may be uploaded, how large, how the
|
||||
* preview URL behaves — must not drift between them. Keep the limits here so
|
||||
* both APIs validate identically and all four web apps can render one consistent
|
||||
* "file too large / type not allowed" message.
|
||||
*/
|
||||
|
||||
/** `FileRecord.resource` discriminator for freight chat attachments. */
|
||||
export const SUPPORT_ATTACHMENT_RESOURCE = "support_message";
|
||||
|
||||
/** Per-file ceiling. Enforced server-side; the UI pre-checks to fail fast. */
|
||||
export const SUPPORT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Max files on a single message. */
|
||||
export const SUPPORT_ATTACHMENT_MAX_PER_MESSAGE = 10;
|
||||
|
||||
/**
|
||||
* How long a minted preview URL stays valid. Long enough that an open thread
|
||||
* doesn't rot mid-read, short enough that a leaked URL isn't a durable grant.
|
||||
*/
|
||||
export const SUPPORT_ATTACHMENT_URL_TTL_SECONDS = 60 * 60;
|
||||
|
||||
/**
|
||||
* Types accepted on a chat message.
|
||||
*
|
||||
* Deliberately NARROWER than `FilesService.ALLOWED_UPLOAD_MIME` (which also
|
||||
* serves generated PDFs and scanned business documents at 25MB). Chat is
|
||||
* user-to-user, so the blast radius of a bad file is another human clicking it.
|
||||
*
|
||||
* `image/svg+xml` is excluded on purpose and must stay excluded: an SVG is
|
||||
* executable markup, and previewing one inline (`<img>` is safe, but an
|
||||
* `<iframe>`/direct navigation is not) executes any script it carries under the
|
||||
* serving origin. Nothing in chat needs vector uploads.
|
||||
*/
|
||||
export const SUPPORT_ATTACHMENT_ALLOWED_MIME: readonly string[] = [
|
||||
// images (previewable inline)
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
// documents
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
"text/plain",
|
||||
];
|
||||
|
||||
/** Image subset — these are the ones worth rendering as a thumbnail. */
|
||||
export const SUPPORT_ATTACHMENT_IMAGE_MIME: readonly string[] = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
];
|
||||
|
||||
export function isSupportAttachmentImage(mimeType: string): boolean {
|
||||
return SUPPORT_ATTACHMENT_IMAGE_MIME.includes(mimeType);
|
||||
}
|
||||
|
||||
export function isSupportAttachmentAllowed(mimeType: string): boolean {
|
||||
return SUPPORT_ATTACHMENT_ALLOWED_MIME.includes(mimeType);
|
||||
}
|
||||
|
||||
/** `accept` attribute for a file input / paste target. */
|
||||
export const SUPPORT_ATTACHMENT_ACCEPT =
|
||||
SUPPORT_ATTACHMENT_ALLOWED_MIME.join(",");
|
||||
@@ -16,12 +16,37 @@
|
||||
* constants shared by the gateway (emitter) and both web apps (subscribers).
|
||||
*/
|
||||
|
||||
import { SUPPORT_ATTACHMENT_RESOURCE } from "../common/support-attachments";
|
||||
|
||||
/** Who authored a message — the customer side or a backoffice agent. */
|
||||
export enum SupportAuthorRole {
|
||||
CUSTOMER = "CUSTOMER",
|
||||
AGENT = "AGENT",
|
||||
}
|
||||
|
||||
/** `FileRecord.resource` value chat attachments are stored under. */
|
||||
export { SUPPORT_ATTACHMENT_RESOURCE };
|
||||
|
||||
/** A file attached to a support message. */
|
||||
export interface SupportAttachmentDto {
|
||||
/**
|
||||
* FileRecord id. For an authenticated download use
|
||||
* `GET /support/attachments/:id?download=1` — the generic `GET /files/:id`
|
||||
* route deliberately refuses chat attachments (it has no ownership check).
|
||||
*/
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
/**
|
||||
* Short-lived signed URL for inline preview (`<img src>`), minted per response.
|
||||
* Expires — see SUPPORT_ATTACHMENT_URL_TTL_SECONDS. Clients must not persist it;
|
||||
* refetch the thread to renew.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** A single chat message on the wire. */
|
||||
export interface SupportMessageDto {
|
||||
id: string;
|
||||
@@ -30,7 +55,9 @@ export interface SupportMessageDto {
|
||||
authorRole: SupportAuthorRole;
|
||||
/** Display name of the author, resolved at send time (best-effort). */
|
||||
authorName?: string | null;
|
||||
/** Empty string for attachment-only messages. */
|
||||
body: string;
|
||||
attachments: SupportAttachmentDto[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -54,9 +81,17 @@ export interface SupportConversationDto {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Post a message. The portal omits the id; the thread is implied by the company. */
|
||||
/**
|
||||
* Post a message. The portal omits the id; the thread is implied by the company.
|
||||
*
|
||||
* Attachments do not travel in this shape — a message carrying files is sent as
|
||||
* `multipart/form-data` with a `body` field plus one or more `attachments` file
|
||||
* parts, so the files are written with `resourceId = message.id` in the same
|
||||
* request. There is no staging area and therefore no orphan-file GC to run.
|
||||
*/
|
||||
export interface SendSupportMessageDto {
|
||||
body: string;
|
||||
/** Optional only when the request carries at least one attachment. */
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** Agent opens a thread with a company that has none yet. */
|
||||
@@ -81,6 +116,24 @@ export interface SupportConversationListResult {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of messages, walking **backwards** from newest.
|
||||
*
|
||||
* Chat pages by keyset, not by offset: an inbound message while an agent is
|
||||
* scrolled back would shift every offset by one and duplicate/skip rows across
|
||||
* pages. The cursor pins a fixed point in (createdAt, id), so concurrent inserts
|
||||
* at the head can't disturb pages already read.
|
||||
*/
|
||||
export interface SupportMessageListResult {
|
||||
/** Oldest-first *within the page*, so a page appends/prepends as a block. */
|
||||
items: SupportMessageDto[];
|
||||
/**
|
||||
* Opaque cursor for the next (older) page; null when the thread's start has
|
||||
* been reached. Pass back as `before`.
|
||||
*/
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
||||
@@ -23,6 +23,33 @@ export enum PassengerSupportSender {
|
||||
AGENT = "AGENT",
|
||||
}
|
||||
|
||||
/**
|
||||
* A file attached to a passenger support message.
|
||||
*
|
||||
* Structurally identical to the freight `SupportAttachmentDto` — kept as its own
|
||||
* declaration because the two namespaces are independently versioned and the
|
||||
* backing stores differ (Prisma `SupportAttachment` here, polymorphic
|
||||
* `FileRecord` in freight). The upload rules themselves are shared: see
|
||||
* `SUPPORT_ATTACHMENT_*` in `common/support-attachments`.
|
||||
*/
|
||||
export interface PassengerSupportAttachmentDto {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
/**
|
||||
* Short-lived signed URL — serves both inline preview and download. Expires
|
||||
* (see SUPPORT_ATTACHMENT_URL_TTL_SECONDS); clients must not persist it,
|
||||
* refetch the thread to renew.
|
||||
*
|
||||
* There is no API-streamed alternative here, unlike freight: this app has no
|
||||
* general file endpoint, so the signed URL is the only handle. It is minted
|
||||
* only into responses the caller was already authorized to receive.
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** A single chat message on the wire. */
|
||||
export interface PassengerSupportMessageDto {
|
||||
id: string;
|
||||
@@ -30,7 +57,9 @@ export interface PassengerSupportMessageDto {
|
||||
sender: PassengerSupportSender;
|
||||
/** Display name of the author, best-effort. */
|
||||
authorName?: string | null;
|
||||
/** Empty string for attachment-only messages. */
|
||||
text: string;
|
||||
attachments: PassengerSupportAttachmentDto[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -74,15 +103,43 @@ export interface CreateGuestSupportConversationDto {
|
||||
initialMessage: string;
|
||||
}
|
||||
|
||||
/** Post a message into an existing conversation. */
|
||||
/**
|
||||
* Post a message into an existing conversation.
|
||||
*
|
||||
* As in freight, attachments travel as `multipart/form-data` (a `text` field
|
||||
* plus `attachments` file parts) rather than as ids in this body, so files are
|
||||
* persisted against the message that owns them in one request.
|
||||
*/
|
||||
export interface SendPassengerSupportMessageDto {
|
||||
text: string;
|
||||
/** Optional only when the request carries at least one attachment. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** The single device-scoped thread for the portal: conversation + its messages. */
|
||||
/**
|
||||
* The single device-scoped thread for the portal: conversation + its newest page
|
||||
* of messages.
|
||||
*
|
||||
* `messages` is the *first page only* (newest N, oldest-first within the page) —
|
||||
* it is not the whole thread. Page backwards with `nextCursor` via the messages
|
||||
* endpoint, exactly as the backoffice does.
|
||||
*/
|
||||
export interface PassengerSupportThreadDto {
|
||||
conversation: PassengerSupportConversationDto | null;
|
||||
messages: PassengerSupportMessageDto[];
|
||||
/** Cursor for the next (older) page; null when the thread's start is loaded. */
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of messages, walking backwards from newest. Keyset — not offset — so a
|
||||
* message arriving while the reader is scrolled back cannot shift or duplicate
|
||||
* pages already fetched. See the freight twin for the full rationale.
|
||||
*/
|
||||
export interface PassengerSupportMessageListResult {
|
||||
/** Oldest-first within the page. */
|
||||
items: PassengerSupportMessageDto[];
|
||||
/** Pass back as `before`; null at the start of the thread. */
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Paginated list envelope for the conversations list endpoints. */
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -841,6 +841,9 @@ importers:
|
||||
jose:
|
||||
specifier: ^5.10.0
|
||||
version: 5.10.0
|
||||
minio:
|
||||
specifier: 7.1.3
|
||||
version: 7.1.3
|
||||
pg:
|
||||
specifier: ^8.21.0
|
||||
version: 8.21.0
|
||||
@@ -893,6 +896,9 @@ importers:
|
||||
'@types/luxon':
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
'@types/multer':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
'@types/node':
|
||||
specifier: ^20.10.6
|
||||
version: 20.19.42
|
||||
|
||||
Reference in New Issue
Block a user