feat: setup the attachment to the passenger api

This commit is contained in:
Nathnael
2026-07-18 09:44:14 +00:00
parent 4f043ea4bf
commit bdcd5e957e
12 changed files with 781 additions and 116 deletions

View File

@@ -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,
},
};

View File

@@ -0,0 +1,52 @@
import { BadRequestException } from '@nestjs/common';
/**
* Keyset cursor for paging a thread backwards from newest.
*
* The sort key is the pair `(createdAt, id)`, not `createdAt` alone: two
* messages can share a millisecond, and a cursor on a non-unique key either
* re-serves or skips the tied rows depending which side of the boundary they
* land on. The id breaks ties with a stable total order.
*
* Deliberately a twin of the freight API's `message-cursor.ts`, not a shared
* import: the two APIs share no runtime package, and @edr/types is Nest-free by
* design (this throws Nest exceptions). The wire format matches so a client can
* treat both chats identically — keep them in step if either changes.
*/
export interface MessageCursor {
createdAt: Date;
id: string;
}
export function encodeMessageCursor(cursor: MessageCursor): string {
return Buffer.from(`${cursor.createdAt.toISOString()}|${cursor.id}`, 'utf8').toString(
'base64url',
);
}
/**
* Parse a client-supplied cursor. Rejects anything malformed rather than
* silently falling back to "first page" — a corrupted cursor that degrades to
* page 1 makes an infinite scroll loop forever over the same rows.
*/
export function decodeMessageCursor(raw: string): MessageCursor {
let decoded: string;
try {
decoded = Buffer.from(raw, 'base64url').toString('utf8');
} catch {
throw new BadRequestException('Malformed pagination cursor.');
}
const separator = decoded.lastIndexOf('|');
if (separator === -1) {
throw new BadRequestException('Malformed pagination cursor.');
}
const createdAt = new Date(decoded.slice(0, separator));
const id = decoded.slice(separator + 1);
if (Number.isNaN(createdAt.getTime()) || !id) {
throw new BadRequestException('Malformed pagination cursor.');
}
return { createdAt, id };
}

View File

@@ -7,21 +7,33 @@ import {
Post,
Query,
Req,
Res,
UnauthorizedException,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { FilesInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiConsumes } from '@nestjs/swagger';
import { SUPPORT_ATTACHMENT_MAX_PER_MESSAGE } from '@edr/types';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SupportService } from './support.service';
import { JwtGuard } from '../../common/jwt.guard';
import {
SUPPORT_ATTACHMENT_FIELD,
supportAttachmentMulterOptions,
} from './attachment-upload.options';
import {
CreateConversationDto,
CreateGuestConversationDto,
DeviceIdBodyDto,
DeviceSendMessageDto,
DeviceThreadQueryDto,
GuestIdBodyDto,
GuestSendMessageDto,
ListConversationsQueryDto,
ListMessagesQueryDto,
SendMessageDto,
UpdateStatusDto,
} from './support.dto';
@@ -32,6 +44,35 @@ function userId(req: any): string {
return id;
}
/**
* Multipart send routes accept `text` + `attachments` file parts; a plain-JSON
* body still works (Multer passes non-multipart requests through untouched), so
* text-only clients are unaffected.
*/
const attachmentsInterceptor = () =>
UseInterceptors(
FilesInterceptor(
SUPPORT_ATTACHMENT_FIELD,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
supportAttachmentMulterOptions,
),
);
/** Swagger body schema for a send route: optional text + optional files. */
const sendBodySchema = (extra: Record<string, unknown> = {}) => ({
schema: {
type: 'object',
properties: {
...extra,
text: { type: 'string' },
attachments: {
type: 'array',
items: { type: 'string', format: 'binary' },
},
},
},
});
@ApiTags('Support')
@Controller('support')
export class SupportController {
@@ -74,19 +115,37 @@ export class SupportController {
@Get('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in one of my conversations' })
messages(@Req() req: any, @Param('id') id: string) {
return this.service.getMessages(id, { iamUserId: userId(req) });
@ApiOperation({
summary: 'List messages in one of my conversations (newest page first)',
description:
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
'page, then pass the previous `nextCursor`. Null means start of thread.',
})
messages(@Req() req: any, @Param('id') id: string, @Query() query: ListMessagesQueryDto) {
return this.service.getMessages(id, query, { iamUserId: userId(req) });
}
@Post('conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@attachmentsInterceptor()
@ApiConsumes('multipart/form-data', 'application/json')
@ApiBody(sendBodySchema())
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Send a message as the customer' })
send(@Req() req: any, @Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
iamUserId: userId(req),
});
send(
@Req() req: any,
@Param('id') id: string,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendMessage(
id,
'USER',
body.text,
{ iamUserId: userId(req) },
attachments ?? [],
);
}
@Post('conversations/:id/read')
@@ -111,16 +170,29 @@ export class SupportController {
@Get('device/thread')
@IsPublic()
@ApiOperation({ summary: "Get the device's support thread + messages" })
deviceThread(@Query('deviceId') deviceId: string) {
return this.service.getDeviceThread(deviceId);
@ApiOperation({
summary: "Get the device's support thread + its newest page of messages",
description:
'`messages` is the newest page only, not the whole thread — page back ' +
'with `nextCursor` via this same route.',
})
deviceThread(@Query() query: DeviceThreadQueryDto) {
return this.service.getDeviceThread(query.deviceId, query);
}
@Post('device/messages')
@IsPublic()
@ApiOperation({ summary: 'Send a message (creates the thread on first send)' })
deviceSend(@Body() body: DeviceSendMessageDto) {
return this.service.sendDeviceMessage(body.deviceId, body.text);
@attachmentsInterceptor()
@ApiConsumes('multipart/form-data', 'application/json')
@ApiBody(sendBodySchema({ deviceId: { type: 'string' } }))
@ApiOperation({
summary: 'Send a message (creates the thread on first send)',
})
deviceSend(
@Body() body: DeviceSendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendDeviceMessage(body.deviceId, body.text, attachments ?? []);
}
@Post('device/read')
@@ -137,6 +209,37 @@ export class SupportController {
return this.service.unreadCount('USER', { guestId: deviceId });
}
// ---- chat attachments --------------------------------------------------
/**
* Serves both audiences (portal device threads and the backoffice inbox) from
* one path, because a new message is pushed to both over the socket in a
* single payload — an identity-bearing URL would be wrong for one of them.
*
* Public for the same reason the device thread is: access to a passenger
* support thread is already whoever-holds-the-id. See `streamAttachment` for
* the full trade-off and the TODO to tighten it with the agent-route gating.
*/
@Get('attachments/:fileId')
@IsPublic()
@ApiOperation({ summary: 'Stream a support chat attachment' })
async attachment(
@Param('fileId') fileId: string,
@Query('download') download: string | undefined,
@Res() res: Response,
) {
const { stream, mimeType, name } = await this.service.streamAttachment(fileId);
const forceDownload = download === '1' || download === 'true';
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
`${forceDownload ? 'attachment' : 'inline'}; filename="${name}"`,
);
res.setHeader('Cache-Control', 'private, max-age=300');
stream.pipe(res);
}
// ---- customer: guest (unauthenticated, multi-ticket) ------------------
// No JwtGuard. Access is scoped by a client-generated `guestId` (the bearer
// of access — anyone with it sees that thread; accepted MVP trade-off).
@@ -150,28 +253,42 @@ export class SupportController {
@Get('guest/conversations')
@IsPublic()
@ApiOperation({ summary: 'List a guest\'s conversations' })
guestList(
@Query('guestId') guestId: string,
@Query() query: ListConversationsQueryDto,
) {
@ApiOperation({ summary: "List a guest's conversations" })
guestList(@Query('guestId') guestId: string, @Query() query: ListConversationsQueryDto) {
return this.service.listForCustomer({ guestId }, query);
}
@Get('guest/conversations/:id/messages')
@IsPublic()
@ApiOperation({ summary: 'List messages in a guest conversation' })
guestMessages(@Param('id') id: string, @Query('guestId') guestId: string) {
return this.service.getMessages(id, { guestId });
@ApiOperation({
summary: 'List messages in a guest conversation (newest page first)',
})
guestMessages(
@Param('id') id: string,
@Query('guestId') guestId: string,
@Query() query: ListMessagesQueryDto,
) {
return this.service.getMessages(id, query, { guestId });
}
@Post('guest/conversations/:id/messages')
@IsPublic()
@attachmentsInterceptor()
@ApiConsumes('multipart/form-data', 'application/json')
@ApiBody(sendBodySchema({ guestId: { type: 'string' } }))
@ApiOperation({ summary: 'Send a message as a guest' })
guestSend(@Param('id') id: string, @Body() body: GuestSendMessageDto) {
return this.service.sendMessage(id, 'USER', body.text, {
guestId: body.guestId,
});
guestSend(
@Param('id') id: string,
@Body() body: GuestSendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendMessage(
id,
'USER',
body.text,
{ guestId: body.guestId },
attachments ?? [],
);
}
@Post('guest/conversations/:id/read')
@@ -183,7 +300,7 @@ export class SupportController {
@Get('guest/unread-count')
@IsPublic()
@ApiOperation({ summary: 'Count a guest\'s unread conversations' })
@ApiOperation({ summary: "Count a guest's unread conversations" })
guestUnread(@Query('guestId') guestId: string) {
return this.service.unreadCount('USER', { guestId });
}
@@ -202,17 +319,29 @@ export class SupportController {
@Get('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'List messages in a conversation' })
agentMessages(@Param('id') id: string) {
return this.service.getMessages(id);
@ApiOperation({
summary: 'List messages in a conversation (newest page first)',
description:
'Keyset-paginated backwards from newest. Omit `before` for the newest ' +
'page, then pass the previous `nextCursor`. Null means start of thread.',
})
agentMessages(@Param('id') id: string, @Query() query: ListMessagesQueryDto) {
return this.service.getMessages(id, query);
}
@Post('agent/conversations/:id/messages')
@UseGuards(JwtGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Reply as an agent' })
agentSend(@Param('id') id: string, @Body() body: SendMessageDto) {
return this.service.sendMessage(id, 'AGENT', body.text);
@attachmentsInterceptor()
@ApiConsumes('multipart/form-data', 'application/json')
@ApiBody(sendBodySchema())
@ApiOperation({ summary: 'Reply as an agent, optionally with attachments' })
agentSend(
@Param('id') id: string,
@Body() body: SendMessageDto,
@UploadedFiles() attachments?: Express.Multer.File[],
) {
return this.service.sendMessage(id, 'AGENT', body.text, undefined, attachments ?? []);
}
@Patch('agent/conversations/:id/status')

View File

@@ -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;
}

View File

@@ -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],
})

View File

@@ -1,16 +1,52 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Passenger as T } from '@edr/types';
import {
isSupportAttachmentAllowed,
Passenger as T,
SUPPORT_ATTACHMENT_MAX_BYTES,
SUPPORT_ATTACHMENT_MAX_PER_MESSAGE,
} from '@edr/types';
import { randomUUID } from 'crypto';
import { Readable } from 'stream';
import { PrismaService } from '../../common/prisma.service';
import { MinioService } from '../storage/minio.service';
import { decodeMessageCursor, encodeMessageCursor } from './message-cursor';
import { SUPPORT_MESSAGES_DEFAULT_LIMIT } from './support.dto';
import { SupportGateway } from './support.gateway';
type Side = 'USER' | 'AGENT';
type PrismaSender = 'USER' | 'BOT' | 'AGENT';
type PrismaStatus = 'OPEN' | 'RESOLVED' | 'CLOSED';
/** Stand-in preview for a message that is nothing but files. */
const ATTACHMENT_ONLY_PREVIEW = '📎';
interface MessagesQuery {
before?: string;
limit?: number;
}
type AttachmentRow = {
id: string;
name: string;
mimeType: string;
size: number;
url: string;
};
type MessageRow = {
id: string;
conversationId: string;
sender: PrismaSender;
text: string | null;
createdAt: Date;
attachments?: AttachmentRow[];
};
/** Who the caller is on the customer side: an authed passenger or a guest. */
export interface CustomerOwner {
iamUserId?: string | null;
@@ -50,6 +86,7 @@ export class SupportService {
constructor(
private prisma: PrismaService,
private gateway: SupportGateway,
private minio: MinioService,
) {}
// ---- FAQ (unchanged) ---------------------------------------------------
@@ -115,29 +152,37 @@ export class SupportService {
// ---- customer: device-scoped single thread (portal) -------------------
/** The device's single conversation + its messages ({conversation:null} if none). */
async getDeviceThread(deviceId: string): Promise<T.PassengerSupportThreadDto> {
if (!deviceId) return { conversation: null, messages: [] };
/**
* The device's single conversation + its NEWEST page of messages
* ({conversation:null} if none). Not the whole thread — the client pages back
* with `nextCursor` exactly as the backoffice does.
*/
async getDeviceThread(
deviceId: string,
query: MessagesQuery = {},
): Promise<T.PassengerSupportThreadDto> {
const empty = { conversation: null, messages: [], nextCursor: null };
if (!deviceId) return empty;
const c = (await this.prisma.supportConversation.findFirst({
where: { guestId: deviceId },
orderBy: { createdAt: 'asc' },
})) as ConversationRow | null;
if (!c) return { conversation: null, messages: [] };
const rows = await this.prisma.supportMessage.findMany({
where: { conversationId: c.id },
orderBy: { createdAt: 'asc' },
});
if (!c) return empty;
const page = await this.listMessages(c.id, query);
const unread = await this.computeUnread([c], 'USER');
return {
conversation: this.toConversationDto(c, unread.get(c.id) ?? 0),
messages: rows.map((m) => this.toMessageDto(m)),
messages: page.items,
nextCursor: page.nextCursor,
};
}
/** Append a message to the device's thread, creating it on first message. */
async sendDeviceMessage(
deviceId: string,
text: string,
text: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<T.PassengerSupportMessageDto> {
let c = (await this.prisma.supportConversation.findFirst({
where: { guestId: deviceId },
@@ -148,9 +193,7 @@ export class SupportService {
data: { guestId: deviceId, subject: 'Support chat', status: 'OPEN' },
})) as ConversationRow;
}
const updated = await this.appendMessage(c, 'USER', text);
const last = updated.messages[updated.messages.length - 1];
return this.toMessageDto(last);
return this.appendMessage(c, 'USER', text, attachments);
}
/** Mark the device's thread read (customer side). */
@@ -186,9 +229,7 @@ export class SupportService {
// ---- agent (backoffice) ------------------------------------------------
async listForAgents(
query: ListQuery,
): Promise<T.PassengerSupportConversationListResult> {
async listForAgents(query: ListQuery): Promise<T.PassengerSupportConversationListResult> {
const where = this.listWhere(query);
const rows = (await this.prisma.supportConversation.findMany({
where,
@@ -216,32 +257,29 @@ export class SupportService {
// ---- shared ------------------------------------------------------------
/** One page of a thread, newest first. See {@link listMessages}. */
async getMessages(
conversationId: string,
query: MessagesQuery = {},
asCustomer?: CustomerOwner,
): Promise<T.PassengerSupportMessageDto[]> {
): Promise<T.PassengerSupportMessageListResult> {
const conversation = await this.requireConversation(conversationId);
if (asCustomer) this.assertOwns(conversation, asCustomer);
const rows = await this.prisma.supportMessage.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
});
return rows.map((m) => this.toMessageDto(m));
return this.listMessages(conversationId, query);
}
async sendMessage(
conversationId: string,
sender: Side,
text: string,
text: string | undefined,
asCustomer?: CustomerOwner,
attachments: Express.Multer.File[] = [],
): Promise<T.PassengerSupportMessageDto> {
const conversation = await this.requireConversation(conversationId);
if (sender === 'USER') {
this.assertOwns(conversation, asCustomer ?? {});
}
const updated = await this.appendMessage(conversation, sender, text);
const last = updated.messages[updated.messages.length - 1];
return this.toMessageDto(last);
return this.appendMessage(conversation, sender, text, attachments);
}
async markRead(
@@ -265,10 +303,7 @@ export class SupportService {
return this.unreadCount('AGENT');
}
async unreadCount(
side: Side,
owner?: CustomerOwner,
): Promise<{ unreadCount: number }> {
async unreadCount(side: Side, owner?: CustomerOwner): Promise<{ unreadCount: number }> {
const rows = (await this.prisma.supportConversation.findMany({
where: side === 'USER' ? this.ownerScope(owner ?? {}) : {},
select: { id: true, userLastReadAt: true, agentLastReadAt: true },
@@ -289,49 +324,197 @@ export class SupportService {
conversation: ConversationRow,
text: string,
): Promise<T.PassengerSupportConversationDto> {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
'USER',
text,
);
const { conversation: updated } = await this.appendMessageRaw(conversation, 'USER', text);
return this.toConversationDto(updated, 0);
}
private async appendMessage(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
) {
const { conversation: updated } = await this.appendMessageRaw(
conversation,
sender,
text,
);
return updated as ConversationRow & { messages: any[] };
text: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<T.PassengerSupportMessageDto> {
const { message } = await this.appendMessageRaw(conversation, sender, text, attachments);
return message;
}
/** Persist a message, bump the conversation's denormalized fields, emit live. */
/**
* One page of a thread, walking backwards from newest.
*
* Keyset, not offset: a message arriving while the reader is scrolled back
* would shift every offset by one and duplicate/skip rows across pages. Rides
* the (conversationId, createdAt) index; the id is a tiebreak for messages
* sharing a millisecond.
*/
private async listMessages(
conversationId: string,
query: MessagesQuery,
): Promise<T.PassengerSupportMessageListResult> {
const limit = query.limit ?? SUPPORT_MESSAGES_DEFAULT_LIMIT;
const before = query.before ? decodeMessageCursor(query.before) : undefined;
const rows = (await this.prisma.supportMessage.findMany({
where: {
conversationId,
...(before
? {
// Strictly older than the cursor in (createdAt, id) order.
OR: [
{ createdAt: { lt: before.createdAt } },
{
createdAt: before.createdAt,
id: { lt: before.id },
},
],
}
: {}),
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
// One more than asked, to tell "there's another page" from "this page was
// simply full" without a second COUNT.
take: limit + 1,
include: { attachments: { orderBy: { createdAt: 'asc' } } },
})) as MessageRow[];
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const oldest = page[page.length - 1];
const nextCursor =
hasMore && oldest
? encodeMessageCursor({ createdAt: oldest.createdAt, id: oldest.id })
: null;
// Flip to oldest-first so a page prepends as one block.
const items = await Promise.all([...page].reverse().map((m) => this.toMessageDto(m)));
return { items, nextCursor };
}
/** Persist a message (+ attachments), bump denormalized fields, emit live. */
private async appendMessageRaw(
conversation: ConversationRow,
sender: PrismaSender,
text: string,
): Promise<{ conversation: ConversationRow & { messages: any[] }; message: any }> {
text: string | undefined,
attachments: Express.Multer.File[] = [],
): Promise<{
conversation: ConversationRow;
message: T.PassengerSupportMessageDto;
}> {
const trimmed = (text ?? '').trim();
this.assertSendable(trimmed, attachments);
const message = await this.prisma.supportMessage.create({
data: { conversationId: conversation.id, sender, text },
// NULL, not "", so "no text" is representable rather than inferred. The
// DTO flattens it back to "" for rendering.
data: { conversationId: conversation.id, sender, text: trimmed || null },
});
// The row has to exist before the files, since each is stored against
// `messageId`. That leaves a window: if an upload fails here, the message is
// already committed. Undo it rather than leave the thread with a
// permanently blank bubble — there is no delete flow, so an orphan would be
// unremovable, and an attachment-only message that lost its files has no
// content at all.
let stored: AttachmentRow[];
try {
stored = await this.storeAttachments(message.id, attachments);
} catch (error) {
await this.prisma.supportMessage.delete({ where: { id: message.id } });
throw error;
}
const updated = (await this.prisma.supportConversation.update({
where: { id: conversation.id },
data: {
lastMessageAt: message.createdAt,
lastMessagePreview: text.slice(0, 280),
lastMessagePreview: this.buildPreview(trimmed, stored),
lastMessageSender: sender,
},
include: { messages: { orderBy: { createdAt: 'asc' } } },
})) as ConversationRow & { messages: any[] };
// Deliberately NOT `include: { messages: ... }` — that loaded every
// message in the thread on every send just to read back the one we had in
// hand.
})) as ConversationRow;
const messageDto = await this.toMessageDto({
...(message as MessageRow),
attachments: stored,
});
const dto = this.toConversationDto(updated, 0);
this.gateway.emitMessage(this.ownerRoom(updated), dto, this.toMessageDto(message));
return { conversation: updated, message };
this.gateway.emitMessage(this.ownerRoom(updated), dto, messageDto);
return { conversation: updated, message: messageDto };
}
/**
* Push bytes to MinIO, then record them. Object keys are namespaced by message
* id and the stored name is sanitized so the key survives the round-trip
* through its own URL (spaces/unicode would otherwise percent-encode and no
* longer match the key).
*/
private async storeAttachments(
messageId: string,
files: Express.Multer.File[],
): Promise<AttachmentRow[]> {
return Promise.all(
files.map(async (file) => {
const safeName = file.originalname
.normalize('NFKD')
.replace(/[^\w.\-]+/g, '_')
.replace(/_{2,}/g, '_')
.replace(/^_+|_+$/g, '');
// The random segment is load-bearing: `Date.now()` is NOT unique across
// this batch, since every callback runs to its first await in the same
// tick and reads the same millisecond. Two files sharing a name — e.g.
// two pasted screenshots, which browsers both call "image.png" — would
// otherwise build the same key and silently overwrite each other.
const objectName = `support_message/${messageId}/${Date.now()}_${randomUUID().slice(0, 8)}_${safeName}`;
const url = await this.minio.uploadFile(objectName, file.buffer, file.mimetype);
return this.prisma.supportAttachment.create({
data: {
messageId,
name: file.originalname,
mimeType: file.mimetype,
size: file.size,
url,
},
});
}),
);
}
/**
* Chat upload rules — kept in step with the freight side via the shared
* SUPPORT_ATTACHMENT_* constants. Notably excludes SVG: it's executable markup
* and this is a file one user pushes at another.
*/
private assertSendable(text: string, attachments: Express.Multer.File[]): void {
if (!text && attachments.length === 0) {
throw new BadRequestException('A message needs text or at least one attachment.');
}
if (attachments.length > SUPPORT_ATTACHMENT_MAX_PER_MESSAGE) {
throw new BadRequestException(
`At most ${SUPPORT_ATTACHMENT_MAX_PER_MESSAGE} files per message.`,
);
}
for (const file of attachments) {
if (!isSupportAttachmentAllowed(file.mimetype)) {
throw new BadRequestException(`Unsupported attachment type: ${file.mimetype}`);
}
if (file.size > SUPPORT_ATTACHMENT_MAX_BYTES) {
throw new BadRequestException(
`"${file.originalname}" exceeds the ${
SUPPORT_ATTACHMENT_MAX_BYTES / (1024 * 1024)
}MB attachment limit.`,
);
}
}
}
/** Inbox preview line — falls back to filenames when there's no text. */
private buildPreview(text: string, attachments: AttachmentRow[]): string {
if (text) return text.slice(0, 280);
if (attachments.length === 1) {
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments[0].name}`.slice(0, 280);
}
return `${ATTACHMENT_ONLY_PREVIEW} ${attachments.length} files`;
}
private async buildListResult(
@@ -340,9 +523,7 @@ export class SupportService {
side: Side,
): Promise<T.PassengerSupportConversationListResult> {
const unreadMap = await this.computeUnread(rows, side);
const items = rows.map((r) =>
this.toConversationDto(r, unreadMap.get(r.id) ?? 0),
);
const items = rows.map((r) => this.toConversationDto(r, unreadMap.get(r.id) ?? 0));
let unreadCount = 0;
for (const n of unreadMap.values()) if (n > 0) unreadCount++;
return { items, count, unreadCount };
@@ -365,10 +546,7 @@ export class SupportService {
select: { conversationId: true, createdAt: true },
});
const cursorById = new Map(
rows.map((r) => [
r.id,
side === 'USER' ? r.userLastReadAt : r.agentLastReadAt,
]),
rows.map((r) => [r.id, side === 'USER' ? r.userLastReadAt : r.agentLastReadAt]),
);
for (const m of msgs) {
const cursor = cursorById.get(m.conversationId) ?? null;
@@ -448,27 +626,75 @@ export class SupportService {
};
}
private toMessageDto(m: {
id: string;
conversationId: string;
sender: PrismaSender;
text: string;
createdAt: Date;
}): T.PassengerSupportMessageDto {
private async toMessageDto(m: MessageRow): Promise<T.PassengerSupportMessageDto> {
return {
id: m.id,
conversationId: m.conversationId,
sender: this.toDtoSender(m.sender) ?? T.PassengerSupportSender.AGENT,
text: m.text,
text: m.text ?? '',
attachments: (m.attachments ?? []).map((a) => this.toAttachmentDto(a)),
createdAt: m.createdAt.toISOString(),
};
}
/**
* Where the client fetches the bytes: this API's own stream route, NOT a
* presigned MinIO URL. Presigned object URLs are not reachable from the
* browser in this deployment, which is why every working file in the platform
* streams through the API instead.
*
* The path is audience-independent on purpose. A new message is pushed over
* the socket to the device room *and* the backoffice room in one payload, so a
* URL that embedded the caller's identity (a `?deviceId=`, say) would be wrong
* for one of the two recipients.
*
* The client can't use this path as an `<img src>` either — the agent side's
* guard only reads a bearer header, which an image request can't send — so the
* web apps fetch it through their authenticated client and render a blob.
*/
private toAttachmentDto(a: AttachmentRow): T.PassengerSupportAttachmentDto {
return {
id: a.id,
name: a.name,
mimeType: a.mimeType,
size: a.size,
url: `/support/attachments/${a.id}`,
};
}
/**
* Bytes for a chat attachment.
*
* Deliberately unscoped, and this is a trade-off worth naming: passenger
* support threads are already reachable by whoever holds the device/guest id
* (see the device routes — "anyone with the device id can see that thread",
* the accepted MVP posture), and the agent routes admit any authenticated
* caller pending real staff gating. Scoping this endpoint tighter than the
* thread it belongs to would buy nothing, so it matches that posture: the
* attachment UUID is the capability.
*
* TODO: tighten alongside the agent-route staff permission — at that point
* both the thread and its attachments should be gated the same way.
*/
async streamAttachment(
fileId: string,
): Promise<{ stream: Readable; mimeType: string; name: string }> {
const attachment = await this.prisma.supportAttachment.findUnique({
where: { id: fileId },
});
if (!attachment) throw new NotFoundException('Attachment not found');
const objectName = this.minio.getObjectNameFromUrl(attachment.url);
return {
stream: await this.minio.getFileStream(objectName),
mimeType: attachment.mimeType,
name: attachment.name,
};
}
/** Legacy BOT messages are surfaced as AGENT to the UI. */
private toDtoSender(s: PrismaSender | null): T.PassengerSupportSender | null {
if (!s) return null;
return s === 'USER'
? T.PassengerSupportSender.USER
: T.PassengerSupportSender.AGENT;
return s === 'USER' ? T.PassengerSupportSender.USER : T.PassengerSupportSender.AGENT;
}
}