mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
feat: setup attachment to the freight chat
This commit is contained in:
@@ -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),
|
||||
);
|
||||
return { conversation, 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.",
|
||||
);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user