mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
import { Injectable, Logger } from "@nestjs/common";
|
|
import { Readable } from "stream";
|
|
|
|
import { FilesService } from "../files/files.service";
|
|
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,
|
|
) {}
|
|
|
|
/** 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: the HTML
|
|
* render paths inline this value into an <img> that headless Chromium
|
|
* cannot fetch over the network. 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. (Contract signing no longer consumes
|
|
* this — staff signatures reference the stampFileId directly.)
|
|
*/
|
|
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.
|
|
*
|
|
* The replaced file is NEVER deleted: contract signatures reference stamp
|
|
* files by id (ContractTransitionService points staff signatures at the
|
|
* current stampFileId instead of copying the image), so each retired file
|
|
* is the immutable record of which seal executed the contracts signed while
|
|
* it was current. Deleting it would strip the seal off those contracts.
|
|
*/
|
|
async setStamp(
|
|
stampImageBase64: string,
|
|
updatedById?: string | null,
|
|
): Promise<StampSettingView> {
|
|
const current = await this.get();
|
|
|
|
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,
|
|
});
|
|
|
|
this.logger.log(`Company stamp updated by ${updatedById ?? "unknown user"}`);
|
|
return this.getView();
|
|
}
|
|
|
|
/**
|
|
* Clear the stamp (invoices fall back to the programmatic seal). The file
|
|
* is kept for the same reason as in {@link setStamp}.
|
|
*/
|
|
async clearStamp(updatedById?: string | null): Promise<StampSettingView> {
|
|
const current = await this.get();
|
|
|
|
await this.repository.update(current.id, {
|
|
stampFileId: null,
|
|
updatedById: updatedById ?? null,
|
|
});
|
|
|
|
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)));
|
|
});
|
|
}
|
|
}
|