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:
Hagernesh
2026-08-13 10:53:42 +00:00
parent 63b40ea4f8
commit c4bf3c9479
31 changed files with 800 additions and 8 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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"] });
}
}

View File

@@ -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)));
});
}
}