import { Injectable } from '@nestjs/common'; import { Readable } from 'stream'; import { DataSource } from 'typeorm'; import { FilesService } from '../files/files.service'; import { FileRecord } from '../files/entities/file.entity'; import { MinioService } from '../minio/minio.service'; import { SignaturesRepository } from './signatures.repository'; import { SavedSignature } from './entities/saved-signature.entity'; import { SavedSignatureDto } from './dto/save-signature.dto'; export interface UpsertSignatureInput { userId: string; signerDisplayName: string; signatureImageBase64: string; } @Injectable() export class SignaturesService { constructor( private readonly signaturesRepository: SignaturesRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly dataSource: DataSource, ) {} /** Saved signature for a user, with the image inlined as a data URL (or null). */ async getForUser(userId: string): Promise { const saved = await this.signaturesRepository.findByUserId(userId); if (!saved) return null; return { signerDisplayName: saved.signerDisplayName, signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url), }; } /** Insert or update the user's reusable signature, storing the image in MinIO. */ async upsertForUser(input: UpsertSignatureInput): Promise { const buffer = this.decodeSignatureImage(input.signatureImageBase64); const file: Express.Multer.File = { fieldname: 'signature', originalname: `signature-${input.userId}.png`, encoding: '7bit', mimetype: 'image/png', size: buffer.length, buffer, stream: Readable.from(buffer), destination: '', filename: '', path: '', }; // Capture the previously referenced file so we can remove it only AFTER the // saved_signatures row is repointed — deleting it first would violate the // FK constraint (saved_signatures.signature_file_id -> files.id). const existing = await this.signaturesRepository.findByUserId(input.userId); const previousFileId = existing?.signatureFileId ?? null; const fileRecord = await this.filesService.upload({ resourceId: input.userId, resource: 'saved_signatures', code: 'signature', file, }); const saved = await this.signaturesRepository.upsert({ userId: input.userId, signerDisplayName: input.signerDisplayName, signatureFileId: fileRecord.id, }); if (previousFileId && previousFileId !== fileRecord.id) { await this.dataSource .getRepository(FileRecord) .delete({ id: previousFileId }); } return saved; } private async inlineImageUrl( url?: string | null, ): Promise { if (!url) return null; if (url.startsWith('data:')) return url; try { const objectName = this.minioService.getObjectNameFromUrl(url); const stream = await this.minioService.getFileStream(objectName); const buffer = await this.streamToBuffer(stream); return `data:image/png;base64,${buffer.toString('base64')}`; } catch { return url; } } private decodeSignatureImage(base64: string): Buffer { const raw = base64.includes(',') ? base64.split(',')[1]! : base64; return Buffer.from(raw, 'base64'); } private streamToBuffer(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', (chunk: Buffer | string) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); stream.on('error', reject); stream.on('end', () => resolve(Buffer.concat(chunks))); }); } }