mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +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. >
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import Handlebars from 'handlebars';
|
|
import { Readable } from 'stream';
|
|
import { DataSource } from 'typeorm';
|
|
import { LastMileRequestStatus } from '@edr/types';
|
|
|
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { BookingsService } from '../bookings/bookings.service';
|
|
import { FilesService } from '../files/files.service';
|
|
import { FileRecord } from '../files/entities/file.entity';
|
|
import { MinioService } from '../minio/minio.service';
|
|
import { SignaturesService } from '../signatures/signatures.service';
|
|
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
|
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
|
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
|
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
|
|
|
const FILE_RESOURCE = 'last_mile_requests';
|
|
|
|
/**
|
|
* The LM contract in front of the advance payment: generated when the chief
|
|
* approves the request, viewed and signed by the customer in the portal, and
|
|
* only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single
|
|
* signer (customer), so the signature lives on the request row itself — no
|
|
* signature-rows table like bookings/CRSP contracts need for multi-role.
|
|
*/
|
|
@Injectable()
|
|
export class LastMileContractService {
|
|
private readonly logger = new Logger(LastMileContractService.name);
|
|
private compiledTemplate: Handlebars.TemplateDelegate | null = null;
|
|
|
|
constructor(
|
|
private readonly requestsRepository: LastMileRequestsRepository,
|
|
private readonly requestsService: LastMileRequestsService,
|
|
private readonly bookingsService: BookingsService,
|
|
private readonly filesService: FilesService,
|
|
private readonly minioService: MinioService,
|
|
private readonly pdfService: ContractPdfService,
|
|
private readonly signaturesService: SignaturesService,
|
|
private readonly dataSource: DataSource,
|
|
private readonly logoSettings: LogoSettingsService,
|
|
) {}
|
|
|
|
async getContractView(id: string, viewerUserId?: string | null) {
|
|
const request = await this.requireApprovedRequest(id);
|
|
const booking = await this.requireBooking(request);
|
|
const view = await this.buildViewModel(request, booking);
|
|
const html = this.render(view);
|
|
const savedSignature = viewerUserId
|
|
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
|
: undefined;
|
|
return {
|
|
requestId: request.id,
|
|
bookingId: request.bookingId,
|
|
bookingReference: booking.reference,
|
|
status: request.status,
|
|
html,
|
|
customerSignedAt: request.customerSignedAt ?? null,
|
|
signerDisplayName: request.signerDisplayName ?? null,
|
|
canSign: !request.customerSignedAt,
|
|
savedSignature,
|
|
};
|
|
}
|
|
|
|
async streamContract(id: string) {
|
|
const request = await this.requireApprovedRequest(id);
|
|
const booking = await this.requireBooking(request);
|
|
const record = await this.upsertContractPdf(request, booking);
|
|
return this.filesService.streamById(record.id);
|
|
}
|
|
|
|
async sign(
|
|
id: string,
|
|
dto: SignLastMileContractDto,
|
|
signerUserId: string | null,
|
|
): Promise<LastMileRequest> {
|
|
const request = await this.requireApprovedRequest(id);
|
|
if (request.customerSignedAt) {
|
|
throw new BadRequestException('This last-mile contract is already signed');
|
|
}
|
|
const booking = await this.requireBooking(request);
|
|
|
|
if (signerUserId) {
|
|
const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId);
|
|
if (companyId && booking.companyId && companyId !== booking.companyId) {
|
|
throw new BadRequestException('This request does not belong to your company');
|
|
}
|
|
}
|
|
|
|
// Drawn signature wins; otherwise fall back to the saved profile signature
|
|
// (same contract-signing convention as modules/contracts).
|
|
let imageBase64 = dto.signatureImageBase64;
|
|
if (!imageBase64 && signerUserId) {
|
|
const saved = await this.signaturesService.getForUser(signerUserId);
|
|
if (saved?.signatureImageUrl?.startsWith('data:')) {
|
|
imageBase64 = saved.signatureImageUrl;
|
|
}
|
|
}
|
|
if (!imageBase64) {
|
|
throw new BadRequestException(
|
|
'No signature image provided and no saved signature on your profile',
|
|
);
|
|
}
|
|
|
|
const buffer = this.decodeSignatureImage(imageBase64);
|
|
const sigFile = this.toUploadFile(
|
|
`signature-customer-${booking.reference ?? request.id}.png`,
|
|
'image/png',
|
|
buffer,
|
|
);
|
|
const fileRecord = await this.filesService.upsertByCode({
|
|
resourceId: request.id,
|
|
resource: FILE_RESOURCE,
|
|
code: 'signature_customer',
|
|
file: sigFile,
|
|
});
|
|
|
|
await this.requestsRepository.update(id, {
|
|
customerSignedAt: new Date(),
|
|
signerDisplayName: dto.signerDisplayName,
|
|
consentText: dto.consentText ?? null,
|
|
} as Partial<LastMileRequest>);
|
|
|
|
// Best-effort: keep the reusable profile signature fresh for next time.
|
|
if (signerUserId && dto.signatureImageBase64) {
|
|
try {
|
|
await this.signaturesService.upsertForUser({
|
|
userId: signerUserId,
|
|
signerDisplayName: dto.signerDisplayName,
|
|
signatureImageBase64: dto.signatureImageBase64,
|
|
});
|
|
} catch (err) {
|
|
this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`);
|
|
}
|
|
}
|
|
|
|
const signed = (await this.requestsRepository.findById(id, {
|
|
relations: { booking: { company: true } },
|
|
}))!;
|
|
|
|
// Render + store the signed PDF, then invoice the advance. PDF failure must
|
|
// not block the invoice — the document re-renders on view/download.
|
|
try {
|
|
await this.upsertContractPdf(signed, booking, fileRecord);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
|
);
|
|
}
|
|
await this.requestsService.generateAdvanceInvoice(signed);
|
|
|
|
return signed;
|
|
}
|
|
|
|
private async upsertContractPdf(
|
|
request: LastMileRequest,
|
|
booking: Booking,
|
|
signatureRecord?: FileRecord,
|
|
): Promise<FileRecord> {
|
|
const view = await this.buildViewModel(request, booking, signatureRecord);
|
|
const html = this.render(view);
|
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
|
const companyName = booking.company?.name ?? 'Customer';
|
|
const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`;
|
|
const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer);
|
|
return this.filesService.upsertByCode({
|
|
resourceId: request.id,
|
|
resource: FILE_RESOURCE,
|
|
code: 'contract',
|
|
file,
|
|
});
|
|
}
|
|
|
|
private async buildViewModel(
|
|
request: LastMileRequest,
|
|
booking: Booking,
|
|
signatureRecord?: FileRecord,
|
|
) {
|
|
const summary = request.contractSummary;
|
|
const containers = request.requestedContainerNumbers ?? [];
|
|
const cargoDescription =
|
|
booking.cargoFreeText || booking.cargoType?.cargoTypeName || null;
|
|
|
|
const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query(
|
|
`SELECT departed_from_djibouti_at AS "departedAt"
|
|
FROM freight.import_djibouti_operations
|
|
WHERE train_schedule_id = $1 AND deleted_at IS NULL
|
|
LIMIT 1`,
|
|
[request.trainScheduleId],
|
|
);
|
|
|
|
return {
|
|
companyName: booking.company?.name ?? 'Customer',
|
|
bookingReference: booking.reference ?? request.bookingId,
|
|
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
|
|
containerCount: containers.length || null,
|
|
containerList: containers.join(', '),
|
|
cargoDescription,
|
|
deliveryAddress: booking.lastMileDeliveryAddress ?? null,
|
|
trainDepartureDate: this.formatDate(departedRows[0]?.departedAt),
|
|
deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—',
|
|
requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—',
|
|
approvalDate: this.formatDate(request.reviewedAt) ?? '—',
|
|
currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB',
|
|
rateLines: (summary?.lines ?? []).map((l) => ({
|
|
description: l.description,
|
|
amount: this.formatAmount(l.amount),
|
|
})),
|
|
estimatedKm: summary?.estimatedKm ?? null,
|
|
advanceAmount: this.formatAmount(
|
|
summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0,
|
|
),
|
|
signature: request.customerSignedAt
|
|
? {
|
|
signerDisplayName: request.signerDisplayName ?? '',
|
|
signedAt: this.formatDate(request.customerSignedAt) ?? '',
|
|
consentText: request.consentText ?? null,
|
|
imageUrl: await this.signatureImageDataUri(request, signatureRecord),
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */
|
|
private async signatureImageDataUri(
|
|
request: LastMileRequest,
|
|
signatureRecord?: FileRecord,
|
|
): Promise<string | null> {
|
|
try {
|
|
const record =
|
|
signatureRecord ??
|
|
(await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer'));
|
|
if (!record.url) return null;
|
|
const objectName = this.minioService.getObjectNameFromUrl(record.url);
|
|
const stream = await this.minioService.getFileStream(objectName);
|
|
const buffer = await this.streamToBuffer(stream);
|
|
return `data:image/png;base64,${buffer.toString('base64')}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private render(view: Record<string, unknown>): string {
|
|
if (!this.compiledTemplate) {
|
|
const source = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'),
|
|
'utf-8',
|
|
);
|
|
this.compiledTemplate = Handlebars.compile(source);
|
|
}
|
|
return this.compiledTemplate(view);
|
|
}
|
|
|
|
private async requireApprovedRequest(id: string): Promise<LastMileRequest> {
|
|
const request = await this.requestsService.findById(id);
|
|
if (request.status !== LastMileRequestStatus.Approved) {
|
|
throw new BadRequestException(
|
|
`The last-mile contract is available once the request is approved (current status: ${request.status})`,
|
|
);
|
|
}
|
|
return request;
|
|
}
|
|
|
|
private async requireBooking(request: LastMileRequest): Promise<Booking> {
|
|
const booking = await this.dataSource.manager.findOne(Booking, {
|
|
where: { id: request.bookingId },
|
|
relations: { company: true, cargoType: true },
|
|
});
|
|
if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`);
|
|
return booking;
|
|
}
|
|
|
|
private formatDate(value?: Date | string | null): string | null {
|
|
if (!value) return null;
|
|
const date = value instanceof Date ? value : new Date(value);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
private formatAmount(value: number): string {
|
|
return Number(value).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
});
|
|
}
|
|
|
|
private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File {
|
|
return {
|
|
fieldname: 'file',
|
|
originalname: name,
|
|
encoding: '7bit',
|
|
mimetype,
|
|
size: buffer.length,
|
|
buffer,
|
|
stream: Readable.from(buffer),
|
|
destination: '',
|
|
filename: '',
|
|
path: '',
|
|
};
|
|
}
|
|
|
|
private decodeSignatureImage(base64: string): Buffer {
|
|
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
|
return Buffer.from(raw, 'base64');
|
|
}
|
|
|
|
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)));
|
|
});
|
|
}
|
|
}
|