From 779af4c3484cc36a8502dab0b44d7e95042447cb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sat, 18 Jul 2026 09:44:14 +0000 Subject: [PATCH] feat: setup the attachment to the passenger api --- apps/edr-passenger-api/package.json | 2 + .../migration.sql | 39 ++ apps/edr-passenger-api/prisma/schema.prisma | 31 +- .../src/modules/storage/minio.config.ts | 20 + .../src/modules/storage/minio.service.ts | 90 +++++ .../src/modules/storage/storage.module.ts | 12 + .../support/attachment-upload.options.ts | 20 + .../src/modules/support/message-cursor.ts | 52 +++ .../src/modules/support/support.controller.ts | 195 ++++++++-- .../src/modules/support/support.dto.ts | 64 ++- .../src/modules/support/support.module.ts | 4 +- .../src/modules/support/support.service.ts | 368 ++++++++++++++---- 12 files changed, 781 insertions(+), 116 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260717090000_support_chat_attachments/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/storage/minio.config.ts create mode 100644 apps/edr-passenger-api/src/modules/storage/minio.service.ts create mode 100644 apps/edr-passenger-api/src/modules/storage/storage.module.ts create mode 100644 apps/edr-passenger-api/src/modules/support/attachment-upload.options.ts create mode 100644 apps/edr-passenger-api/src/modules/support/message-cursor.ts diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index cdfe68787..a9ee85156 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -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", diff --git a/apps/edr-passenger-api/prisma/migrations/20260717090000_support_chat_attachments/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260717090000_support_chat_attachments/migration.sql new file mode 100644 index 000000000..ca2605812 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260717090000_support_chat_attachments/migration.sql @@ -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; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 62f658967..09a1ffa8c 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -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") } diff --git a/apps/edr-passenger-api/src/modules/storage/minio.config.ts b/apps/edr-passenger-api/src/modules/storage/minio.config.ts new file mode 100644 index 000000000..67df0f0ef --- /dev/null +++ b/apps/edr-passenger-api/src/modules/storage/minio.config.ts @@ -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', +})); diff --git a/apps/edr-passenger-api/src/modules/storage/minio.service.ts b/apps/edr-passenger-api/src/modules/storage/minio.service.ts new file mode 100644 index 000000000..f10e0babf --- /dev/null +++ b/apps/edr-passenger-api/src/modules/storage/minio.service.ts @@ -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, + ) { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/apps/edr-passenger-api/src/modules/storage/storage.module.ts b/apps/edr-passenger-api/src/modules/storage/storage.module.ts new file mode 100644 index 000000000..14ae3de45 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/storage/storage.module.ts @@ -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 {} diff --git a/apps/edr-passenger-api/src/modules/support/attachment-upload.options.ts b/apps/edr-passenger-api/src/modules/support/attachment-upload.options.ts new file mode 100644 index 000000000..9a182436c --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/attachment-upload.options.ts @@ -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, + }, +}; diff --git a/apps/edr-passenger-api/src/modules/support/message-cursor.ts b/apps/edr-passenger-api/src/modules/support/message-cursor.ts new file mode 100644 index 000000000..5194ad808 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/support/message-cursor.ts @@ -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 }; +} diff --git a/apps/edr-passenger-api/src/modules/support/support.controller.ts b/apps/edr-passenger-api/src/modules/support/support.controller.ts index 4404c3ea8..563e71104 100644 --- a/apps/edr-passenger-api/src/modules/support/support.controller.ts +++ b/apps/edr-passenger-api/src/modules/support/support.controller.ts @@ -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 = {}) => ({ + 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') diff --git a/apps/edr-passenger-api/src/modules/support/support.dto.ts b/apps/edr-passenger-api/src/modules/support/support.dto.ts index a60ac607e..4936cb2e3 100644 --- a/apps/edr-passenger-api/src/modules/support/support.dto.ts +++ b/apps/edr-passenger-api/src/modules/support/support.dto.ts @@ -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; +} diff --git a/apps/edr-passenger-api/src/modules/support/support.module.ts b/apps/edr-passenger-api/src/modules/support/support.module.ts index 136fcf196..e4dd50b25 100644 --- a/apps/edr-passenger-api/src/modules/support/support.module.ts +++ b/apps/edr-passenger-api/src/modules/support/support.module.ts @@ -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], }) diff --git a/apps/edr-passenger-api/src/modules/support/support.service.ts b/apps/edr-passenger-api/src/modules/support/support.service.ts index 77f701bf2..89929626d 100644 --- a/apps/edr-passenger-api/src/modules/support/support.service.ts +++ b/apps/edr-passenger-api/src/modules/support/support.service.ts @@ -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 { - 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 { + 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 { 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 { + async listForAgents(query: ListQuery): Promise { 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 { + ): Promise { 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 { 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 { - 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 { + 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 { + 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 { + 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 { 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 { 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 `` 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; } }