mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
feat(freight): add company logo setting applied to every generated document
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. >
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateLogoSettingDto {
|
||||
@ApiProperty({ description: "Logo image as a base64 data URL (PNG/JPG)." })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
logoImageBase64!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { FileRecord } from "../../files/entities/file.entity";
|
||||
|
||||
/**
|
||||
* Single-row table holding the one company logo image stamped onto every
|
||||
* generated document (invoices/receipts, contracts, warehouse papers,
|
||||
* train-scheduling manifests, payment receipts). Same single-row shape as
|
||||
* stamp_settings — `get()` lazily creates the row, and there is never more
|
||||
* than one.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "logo_settings" })
|
||||
export class LogoSetting extends BaseEntity {
|
||||
@Column({ name: "logo_file_id", type: "uuid", nullable: true })
|
||||
logoFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: "logo_file_id" })
|
||||
logoFile?: FileRecord | null;
|
||||
|
||||
/** IAM user id of the last operator to set/clear the logo. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Delete, Get, Put } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateLogoSettingDto } from "./dto/update-logo-setting.dto";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
@ApiTags("logo-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("logo-settings")
|
||||
export class LogoSettingsController {
|
||||
constructor(private readonly service: LogoSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.view, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Current company logo used on every generated document" })
|
||||
get() {
|
||||
return this.service.getView();
|
||||
}
|
||||
|
||||
@Put()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({ summary: "Replace the company logo" })
|
||||
update(@Body() dto: UpdateLogoSettingDto, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.setLogo(dto.logoImageBase64, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@BookingStaff([FREIGHT_PERMS.settings.logo.manage, FREIGHT_PERMS.admin])
|
||||
@ApiOperation({
|
||||
summary: "Clear the company logo (documents fall back to their text mark)",
|
||||
})
|
||||
clear(@CurrentUser() user: TCurrentUser) {
|
||||
return this.service.clearLogo(user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
import { LogoSettingsController } from "./logo-settings.controller";
|
||||
import { LogoSettingsRepository } from "./logo-settings.repository";
|
||||
import { LogoSettingsService } from "./logo-settings.service";
|
||||
|
||||
/**
|
||||
* Global so every document-generating module (billing, contracts,
|
||||
* warehouses, train-scheduling, payment) can inject {@link LogoSettingsService}
|
||||
* without pulling in a circular dependency — same reasoning as
|
||||
* StampSettingsModule.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([LogoSetting]), FilesModule, MinioModule],
|
||||
controllers: [LogoSettingsController],
|
||||
providers: [LogoSettingsRepository, LogoSettingsService],
|
||||
exports: [LogoSettingsService],
|
||||
})
|
||||
export class LogoSettingsModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
|
||||
import { LogoSetting } from "./entities/logo-setting.entity";
|
||||
|
||||
@Injectable()
|
||||
export class LogoSettingsRepository extends BaseRepository<LogoSetting> {
|
||||
constructor(
|
||||
@InjectRepository(LogoSetting)
|
||||
repo: Repository<LogoSetting>,
|
||||
) {
|
||||
super(repo);
|
||||
}
|
||||
|
||||
/** The single settings row, with its logo file joined, or null before first upload. */
|
||||
findSingleton(): Promise<LogoSetting | null> {
|
||||
return this.repository.findOne({ where: {}, relations: ["logoFile"] });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user