import { BadRequestException, Controller, Get, Param, Post, Query, Req, UploadedFile, UseGuards, UseInterceptors, StreamableFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { tmpdir } from 'node:os'; import { extname } from 'node:path'; import { randomUUID } from 'node:crypto'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, ApiParam, ApiQuery, ApiTags, } from '@nestjs/swagger'; import type { Request } from 'express'; import { AdminGuard } from 'src/auth/guards/admin.guard'; import { AdminIdentity, CurrentIdentity } from 'src/common/decorators/Identity.decorator'; import { Permissions } from 'src/common/decorators/permission.decorator'; import { Permission } from 'src/common/types/permissions.catalog'; import { AdminDocument } from 'src/database/model/admin.model'; import { MAX_CHAT_VOICE_BYTES } from 'src/storage/storage.constants'; import { AttachmentSessionExpertGuard } from './attachment-session-expert.guard'; import { AttachmentSessionUserGuard } from './attachment-session-user.guard'; import { ChatAttachmentsService } from './chat-attachments.service'; const attachmentDiskStorage = diskStorage({ destination: (_req, _file, cb) => cb(null, tmpdir()), filename: (_req, file, cb) => { const raw = extname(file.originalname || '').toLowerCase().slice(0, 24); const safe = /^\.[a-z0-9.]{1,20}$/.test(raw) ? raw : ''; cb(null, `chat-att-${randomUUID()}${safe}`); }, }); @ApiTags('chat-attachments') @Controller('chat/attachments') @ApiBearerAuth() export class ChatAttachmentsController { constructor(private readonly attachments: ChatAttachmentsService) {} private parseDurationSec(raw: unknown): number | undefined { if (raw === undefined || raw === null || raw === '') return undefined; const n = Number(raw); return Number.isFinite(n) ? n : undefined; } @Post('user/:sessionId') @ApiOperation({ summary: 'Upload chat attachment (voice FFmpeg→m4a, no image, no PDF). Then sendMessage with message=storageKey, presetMessageId, type matching fileType.', }) @ApiParam({ name: 'sessionId' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', required: ['file', 'fileType'], properties: { file: { type: 'string', format: 'binary' }, fileType: { type: 'string', enum: ['voice'], //? 'image', 'document' }, durationSec: { type: 'number', description: 'Voice: optional duration hint' }, }, }, }) @UseGuards(AttachmentSessionUserGuard) @UseInterceptors( FileInterceptor('file', { storage: attachmentDiskStorage, limits: { fileSize: MAX_CHAT_VOICE_BYTES }, }), ) uploadUser( @Param('sessionId') sessionId: string, @UploadedFile() file: Express.Multer.File, @Req() req: Request, @CurrentIdentity() user: { _id?: unknown }, ) { if (!file) throw new BadRequestException('file_required'); if (!user?._id) throw new BadRequestException('user_token_required'); const fileType = this.attachments.parseFileType( (req.body as Record)?.fileType, ); return this.attachments.uploadAttachment({ sessionId, file, uploaderId: String(user._id), uploaderRole: 'User', fileType, durationSec: this.parseDurationSec( (req.body as Record)?.durationSec, ), }); } @Post('expert/:sessionId') @ApiOperation({ summary: 'Upload chat attachment (expert JWT)' }) @ApiParam({ name: 'sessionId' }) @ApiConsumes('multipart/form-data') @ApiBody({ schema: { type: 'object', required: ['file', 'fileType'], properties: { file: { type: 'string', format: 'binary' }, fileType: { type: 'string', enum: ['voice', 'image', 'document'] }, durationSec: { type: 'number' }, }, }, }) @UseGuards(AdminGuard, AttachmentSessionExpertGuard) @Permissions(Permission.AttachmentsExpert) @UseInterceptors( FileInterceptor('file', { storage: attachmentDiskStorage, limits: { fileSize: MAX_CHAT_VOICE_BYTES }, }), ) uploadExpert( @Param('sessionId') sessionId: string, @UploadedFile() file: Express.Multer.File, @Req() req: Request, @AdminIdentity() admin: AdminDocument, ) { if (!file) throw new BadRequestException('file_required'); const fileType = this.attachments.parseFileType( (req.body as Record)?.fileType, ); return this.attachments.uploadAttachment({ sessionId, file, uploaderId: String(admin._id), uploaderRole: 'Expert', fileType, durationSec: this.parseDurationSec( (req.body as Record)?.durationSec, ), }); } @Get('user/:sessionId/stream') @ApiOperation({ summary: 'Stream a chat attachment (private object). Query: key = full storageKey.', }) @ApiParam({ name: 'sessionId' }) @ApiQuery({ name: 'key', required: true, description: 'Full object key (same as message text for attachment messages)', example: 'chats/.../voice/....m4a', }) @UseGuards(AttachmentSessionUserGuard) async streamUser( @Param('sessionId') sessionId: string, @Query('key') storageKey: string, ): Promise { const { stream, contentType } = await this.attachments.streamPrivateAttachment({ sessionId, storageKey, }); return new StreamableFile(stream, { type: contentType, disposition: `inline; filename="attachment"`, }); } @Get('expert/:sessionId/stream') @ApiOperation({ summary: 'Stream attachment (expert JWT)' }) @ApiParam({ name: 'sessionId' }) @ApiQuery({ name: 'key', required: true }) @UseGuards(AdminGuard, AttachmentSessionExpertGuard) async streamExpert( @Param('sessionId') sessionId: string, @Query('key') storageKey: string, ): Promise { const { stream, contentType } = await this.attachments.streamPrivateAttachment({ sessionId, storageKey, }); return new StreamableFile(stream, { type: contentType, disposition: `inline; filename="attachment"`, }); } }