import { Injectable, Logger } 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 { StampSettingsRepository } from "./stamp-settings.repository"; import { StampSetting } from "./entities/stamp-setting.entity"; export interface StampSettingView { stampImageUrl: string | null; updatedById: string | null; updatedAt: Date | null; } /** * Owns the single `stamp_settings` row: the one company stamp/seal image used * on generated invoice/receipt PDFs (see InvoiceDocumentService). Same * single-row shape as ExchangeSettingsService, but the value is an uploaded * image (via FilesService) rather than a scalar. */ @Injectable() export class StampSettingsService { private readonly logger = new Logger(StampSettingsService.name); constructor( private readonly repository: StampSettingsRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, private readonly dataSource: DataSource, ) {} /** The settings row, created empty on first access. */ async get(): Promise { const existing = await this.repository.findSingleton(); if (existing) return existing; return this.repository.create({ stampFileId: null, updatedById: null }); } /** Current stamp, with the image inlined as a data URL (or null if unset). */ async getView(): Promise { const setting = await this.get(); return { stampImageUrl: await this.inlineImageUrl(setting.stampFile?.url), updatedById: setting.updatedById ?? null, updatedAt: setting.updatedAt ?? null, }; } /** * The stamp image for embedding into generated documents, ALWAYS as a * `data:` URL or null. Never throws — document generation must succeed even * if the stamp lookup fails; callers fall back to their own seal on null. * * The data-URL-or-null guarantee is load-bearing, not cosmetic. Callers do * two things with this value that a bare MinIO URL silently corrupts: * ContractTransitionService base64-decodes it to snapshot the seal onto a * signature row (a URL decodes to garbage bytes, not an error, permanently * sealing an executed contract with a broken image), and the HTML render * path inlines it into an that headless Chromium cannot fetch. So * where getView() may hand a raw URL to a browser that can load it, this * degrades to null and lets the caller draw its text/vector seal instead. */ async getStampImageUrl(): Promise { try { const setting = await this.get(); const inlined = await this.inlineImageUrl(setting.stampFile?.url); if (inlined && !inlined.startsWith("data:")) { this.logger.warn( `Company stamp could not be inlined for document rendering (falling back to the plain seal): ${inlined}`, ); return null; } return inlined; } catch (err) { this.logger.warn( `Could not load company stamp for PDF rendering: ${(err as Error).message}`, ); return null; } } /** Replace the stamp image, storing it in MinIO via FilesService. */ async setStamp( stampImageBase64: string, updatedById?: string | null, ): Promise { const current = await this.get(); const previousFileId = current.stampFileId ?? null; const fileRecord = await this.filesService.upload({ resourceId: current.id, resource: "stamp_settings", code: "stamp", file: this.toUploadFile(stampImageBase64), uploadedByUserId: updatedById ?? null, }); await this.repository.update(current.id, { stampFileId: fileRecord.id, updatedById: updatedById ?? null, }); if (previousFileId && previousFileId !== fileRecord.id) { await this.dataSource.getRepository(FileRecord).delete(previousFileId); } this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`); return this.getView(); } /** Clear the stamp (invoices fall back to the programmatic seal). */ async clearStamp(updatedById?: string | null): Promise { const current = await this.get(); const previousFileId = current.stampFileId ?? null; await this.repository.update(current.id, { stampFileId: null, updatedById: updatedById ?? null, }); if (previousFileId) { await this.dataSource.getRepository(FileRecord).delete(previousFileId); } return this.getView(); } private toUploadFile(base64: string): Express.Multer.File { const raw = base64.includes(",") ? base64.split(",")[1]! : base64; const buffer = Buffer.from(raw, "base64"); return { fieldname: "stamp", originalname: "company-stamp.png", encoding: "7bit", mimetype: "image/png", size: buffer.length, buffer, stream: Readable.from(buffer), destination: "", filename: "", path: "", }; } 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 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))); }); } }