import { randomUUID } from 'node:crypto'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { BusinessRuleError } from '@task-stream/api/shared/domain'; import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage'; import { AttachFileCommand, AttachmentResult } from './attach-file.command'; import { ack } from './command-result'; import { WriteAccess } from './write-access'; /** * Stores the bytes first and records them on the ticket after: a file with no * record is cleaned up here, never a record with no file. */ @CommandHandler(AttachFileCommand) export class AttachFileCommandHandler implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly storage: AttachmentStorage, ) {} async execute(command: AttachFileCommand): Promise { const { issue } = await this.access.issueForReporter( command.issueId, command.actorId, ); if (command.commentId) { const comment = await this.access.comment(command.commentId); if (comment.issueId !== issue.id) { throw new BusinessRuleError( `That comment belongs to another ticket, not ${issue.key}`, ); } } const id = randomUUID(); const key = attachmentKey(issue.projectId, issue.id, id); issue.attach(command.actorId, { id, fileName: command.file.fileName, mimeType: command.file.mimeType, size: command.file.content.length, uploadedBy: command.actorId, uploadedAt: new Date().toISOString(), commentId: command.commentId, }); await this.storage.put(key, command.file.content); try { await issue.commitAndWait(); } catch (error) { await this.storage.remove(key); throw error; } // The id lets a client point at the file, e.g. an image inside a comment. return { ...ack(issue), attachmentId: id }; } }