mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
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<StampSetting> {
|
|
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<StampSettingView> {
|
|
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 <img> 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<string | null> {
|
|
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<StampSettingView> {
|
|
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<StampSettingView> {
|
|
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<string | null> {
|
|
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<Buffer> {
|
|
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)));
|
|
});
|
|
}
|
|
}
|