add reusable per-user contract signatures with profile prefill and approval

This commit is contained in:
Marshal
2026-06-16 13:34:04 +00:00
parent 62ce2dc64f
commit 524d3b6418
12 changed files with 307 additions and 3 deletions

View File

@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module';
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
LocomotivesModule,

View File

@@ -0,0 +1,22 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE freight.saved_signatures (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
signer_display_name VARCHAR(200) NOT NULL,
signature_file_id UUID NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
}
}

View File

@@ -23,6 +23,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@Injectable()
export class BookingContractService {
@@ -38,6 +39,7 @@ export class BookingContractService {
private readonly pdfService: ContractPdfService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly signaturesService: SignaturesService,
) {}
buildContractSummary(booking: Booking): string {
@@ -75,10 +77,16 @@ export class BookingContractService {
return { summary };
}
async getContractView(bookingId: string): Promise<ContractViewDto> {
async getContractView(
bookingId: string,
viewerUserId?: string,
): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const savedSignature = viewerUserId
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
: undefined;
return {
bookingId: view.bookingId,
reference: view.reference,
@@ -90,6 +98,7 @@ export class BookingContractService {
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures: view.signatures,
savedSignature,
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
};
}
@@ -194,6 +203,23 @@ export class BookingContractService {
ipAddress: options.ipAddress ?? null,
});
// Persist the just-used signature to the signer's reusable profile so they
// don't have to redraw it on the next contract. Best-effort: a failure here
// must never block contract execution.
if (options.signerUserId) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
);
}
}
const updates: Record<string, unknown> = {};
if (role === 'CUSTOMER') {

View File

@@ -342,8 +342,12 @@ export class BookingsController {
@Get(':id/contract/view')
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
getContractView(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getContractView(id);
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')

View File

@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
@@ -50,6 +51,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
CompaniesModule,
// CustomersModule,
RuleEngineModule,
SignaturesModule,
],
controllers: [BookingsController, PayController],
providers: [

View File

@@ -14,6 +14,14 @@ export class ContractSignatureDto {
signatureImageUrl?: string | null;
}
export class SavedSignatureViewDto {
@ApiProperty()
signerDisplayName!: string;
@ApiPropertyOptional()
signatureImageUrl?: string | null;
}
export class ContractViewDto {
@ApiProperty()
bookingId!: string;
@@ -45,6 +53,9 @@ export class ContractViewDto {
@ApiProperty({ type: [ContractSignatureDto] })
signatures!: ContractSignatureDto[];
@ApiPropertyOptional({ type: SavedSignatureViewDto })
savedSignature?: SavedSignatureViewDto;
@ApiPropertyOptional()
pricingSchedule?: Record<string, unknown>;
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class SaveSignatureDto {
@ApiProperty()
@IsString()
@MinLength(1)
signerDisplayName!: string;
@ApiProperty({
description: 'PNG signature image as base64 (with or without data URL prefix)',
})
@IsString()
@MinLength(20)
signatureImageBase64!: string;
}
export class SavedSignatureDto {
@ApiProperty()
signerDisplayName!: string;
@ApiProperty({ nullable: true })
signatureImageUrl!: string | null;
}

View File

@@ -0,0 +1,25 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { FileRecord } from '../../files/entities/file.entity';
/**
* A reusable signature that belongs to a single user (customer or staff).
* Captured once and applied to many booking contracts so the signer does not
* have to redraw it every time. One active saved signature per user.
*/
@Entity({ schema: 'freight', name: 'saved_signatures' })
@Index(['userId'], { unique: true })
export class SavedSignature extends BaseEntity {
@Column({ name: 'user_id', type: 'uuid' })
userId!: string;
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
signerDisplayName!: string;
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
signatureFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
}

View File

@@ -0,0 +1,39 @@
import { Body, Controller, Get, Put, Request } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SignaturesService } from './signatures.service';
import { SaveSignatureDto, SavedSignatureDto } from './dto/save-signature.dto';
@ApiTags('Signatures')
@Controller('me/signature')
export class SignaturesController {
constructor(private readonly signaturesService: SignaturesService) {}
@Get()
@ApiOkResponse({ type: SavedSignatureDto })
@ApiOperation({ summary: "Current user's reusable saved signature" })
getMySignature(
@Request() req: { user?: { id?: string; sub?: string } },
): Promise<SavedSignatureDto | null> {
const userId = req.user?.id ?? req.user?.sub;
if (!userId) return Promise.resolve(null);
return this.signaturesService.getForUser(userId);
}
@Put()
@ApiOkResponse({ type: SavedSignatureDto })
@ApiOperation({ summary: 'Create or update the reusable saved signature' })
async saveMySignature(
@Body() dto: SaveSignatureDto,
@Request() req: { user?: { id?: string; sub?: string } },
): Promise<SavedSignatureDto | null> {
const userId = req.user?.id ?? req.user?.sub;
if (!userId) return null;
await this.signaturesService.upsertForUser({
userId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
return this.signaturesService.getForUser(userId);
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { SignaturesController } from './signatures.controller';
import { SignaturesService } from './signatures.service';
import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity';
@Module({
imports: [
TypeOrmModule.forFeature([SavedSignature]),
FilesModule,
MinioModule,
],
controllers: [SignaturesController],
providers: [SignaturesService, SignaturesRepository],
exports: [SignaturesService],
})
export class SignaturesModule {}

View File

@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { SavedSignature } from './entities/saved-signature.entity';
@Injectable()
export class SignaturesRepository extends BaseRepository<SavedSignature> {
constructor(
@InjectRepository(SavedSignature)
repo: Repository<SavedSignature>,
) {
super(repo);
}
findByUserId(userId: string): Promise<SavedSignature | null> {
return this.repository.findOne({
where: { userId } as never,
relations: ['signatureFile'],
});
}
/** Insert or update the single saved signature for a user. */
async upsert(data: Partial<SavedSignature>): Promise<SavedSignature> {
const existing = await this.repository.findOne({
where: { userId: data.userId! } as never,
});
if (existing) {
Object.assign(existing, data);
return this.repository.save(existing);
}
return this.repository.save(this.repository.create(data));
}
}

View File

@@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import { Readable } from 'stream';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { SignaturesRepository } from './signatures.repository';
import { SavedSignature } from './entities/saved-signature.entity';
import { SavedSignatureDto } from './dto/save-signature.dto';
export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
}
@Injectable()
export class SignaturesService {
constructor(
private readonly signaturesRepository: SignaturesRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
) {}
/** Saved signature for a user, with the image inlined as a data URL (or null). */
async getForUser(userId: string): Promise<SavedSignatureDto | null> {
const saved = await this.signaturesRepository.findByUserId(userId);
if (!saved) return null;
return {
signerDisplayName: saved.signerDisplayName,
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
};
}
/** Insert or update the user's reusable signature, storing the image in MinIO. */
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
const file: Express.Multer.File = {
fieldname: 'signature',
originalname: `signature-${input.userId}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
resourceId: input.userId,
resource: 'saved_signatures',
code: 'signature',
file,
});
return this.signaturesRepository.upsert({
userId: input.userId,
signerDisplayName: input.signerDisplayName,
signatureFileId: fileRecord.id,
});
}
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 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)));
});
}
}