mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
New logo-settings module (mirrors stamp-settings): single uploaded logo, stored via FilesService/MinIO, injected as a data URL into invoice/receipt, contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a matching backoffice settings page and settings:logo:view/manage permissions. >
162 lines
5.2 KiB
TypeScript
162 lines
5.2 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 { LogoSettingsRepository } from "./logo-settings.repository";
|
|
import { LogoSetting } from "./entities/logo-setting.entity";
|
|
|
|
export interface LogoSettingView {
|
|
logoImageUrl: string | null;
|
|
updatedById: string | null;
|
|
updatedAt: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Owns the single `logo_settings` row: the one company logo image used on
|
|
* every generated document. Same single-row shape as StampSettingsService,
|
|
* the value is an uploaded image (via FilesService) rather than a scalar.
|
|
*/
|
|
@Injectable()
|
|
export class LogoSettingsService {
|
|
private readonly logger = new Logger(LogoSettingsService.name);
|
|
|
|
constructor(
|
|
private readonly repository: LogoSettingsRepository,
|
|
private readonly filesService: FilesService,
|
|
private readonly minioService: MinioService,
|
|
private readonly dataSource: DataSource,
|
|
) {}
|
|
|
|
/** The settings row, created empty on first access. */
|
|
async get(): Promise<LogoSetting> {
|
|
const existing = await this.repository.findSingleton();
|
|
if (existing) return existing;
|
|
return this.repository.create({ logoFileId: null, updatedById: null });
|
|
}
|
|
|
|
/** Current logo, with the image inlined as a data URL (or null if unset). */
|
|
async getView(): Promise<LogoSettingView> {
|
|
const setting = await this.get();
|
|
return {
|
|
logoImageUrl: await this.inlineImageUrl(setting.logoFile?.url),
|
|
updatedById: setting.updatedById ?? null,
|
|
updatedAt: setting.updatedAt ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The logo image for embedding into generated documents, ALWAYS as a
|
|
* `data:` URL or null. Never throws — document generation must succeed even
|
|
* if the logo lookup fails; callers render their existing text/mark
|
|
* fallback on null (see logo-markup.util.ts).
|
|
*/
|
|
async getLogoImageUrl(): Promise<string | null> {
|
|
try {
|
|
const setting = await this.get();
|
|
const inlined = await this.inlineImageUrl(setting.logoFile?.url);
|
|
if (inlined && !inlined.startsWith("data:")) {
|
|
this.logger.warn(
|
|
`Company logo could not be inlined for document rendering (falling back to the text mark): ${inlined}`,
|
|
);
|
|
return null;
|
|
}
|
|
return inlined;
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Could not load company logo for PDF rendering: ${(err as Error).message}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Replace the logo image, storing it in MinIO via FilesService. */
|
|
async setLogo(
|
|
logoImageBase64: string,
|
|
updatedById?: string | null,
|
|
): Promise<LogoSettingView> {
|
|
const current = await this.get();
|
|
const previousFileId = current.logoFileId ?? null;
|
|
|
|
const fileRecord = await this.filesService.upload({
|
|
resourceId: current.id,
|
|
resource: "logo_settings",
|
|
code: "logo",
|
|
file: this.toUploadFile(logoImageBase64),
|
|
uploadedByUserId: updatedById ?? null,
|
|
});
|
|
|
|
await this.repository.update(current.id, {
|
|
logoFileId: fileRecord.id,
|
|
updatedById: updatedById ?? null,
|
|
});
|
|
|
|
if (previousFileId && previousFileId !== fileRecord.id) {
|
|
await this.dataSource.getRepository(FileRecord).delete(previousFileId);
|
|
}
|
|
|
|
this.logger.log(`Company logo updated by ${updatedById ?? "unknown user"}`);
|
|
return this.getView();
|
|
}
|
|
|
|
/** Clear the logo (documents fall back to their text/mark). */
|
|
async clearLogo(updatedById?: string | null): Promise<LogoSettingView> {
|
|
const current = await this.get();
|
|
const previousFileId = current.logoFileId ?? null;
|
|
|
|
await this.repository.update(current.id, {
|
|
logoFileId: 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: "logo",
|
|
originalname: "company-logo.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)));
|
|
});
|
|
}
|
|
}
|