add company stamp upload functionality for contract signing

- Introduced StampUpload component for uploading company stamp images.
- Integrated stamp upload in contract signing modal, supporting PNG and JPG formats.
- Implemented validation for file type and size (max 5 MB).
- Added visual feedback for drag-and-drop functionality.
- Updated contract-related pages to handle duplicate contract alerts and pricing notices.
- Enhanced contract expiry management with a nightly sweep service.
- Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
Marshal
2026-07-25 17:14:58 +00:00
parent 54b5882355
commit fde5e6de4b
68 changed files with 1858 additions and 289 deletions

View File

@@ -0,0 +1,44 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { NotificationAudience, NotificationType } from '@edr/types';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { ContractsRepository } from './contracts.repository';
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
@Injectable()
export class ContractExpiryService {
private readonly logger = new Logger(ContractExpiryService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly inbox: NotificationInboxService,
) {}
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
async expireLapsedContracts(): Promise<void> {
try {
const affected = await this.contractsRepository.expireLapsedContracts();
this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`);
} catch (err) {
this.logger.error(
`Contract expiry sweep failed: ${(err as Error).message}`,
(err as Error).stack,
);
try {
await this.inbox.notify({
recipients: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.GENERIC,
title: 'Contract expiry sweep failed',
body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`,
data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' },
});
} catch (notifyErr) {
this.logger.error(
`Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`,
);
}
}
}
}

View File

@@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
switch (rateUnit) {
case 'PER_TON':
return 'per_ton';
case 'PER_ITEM':
return 'per_item';
case 'PER_KM':
return 'per_km';
case 'PER_WAGON':
@@ -115,9 +117,19 @@ export class ContractPricingService {
});
}
} else {
const bulkRate =
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
// Freeze the rate for the contract's own commodity when one is configured
// — a per-item machinery rate and a per-ton wheat rate live side by side.
const bulkRates = liveRates.filter(
(r) => r.rateType === baseType && r.currency === 'USD',
);
const bulkRate =
(cargoScope?.cargoTypeId
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
: undefined) ??
bulkRates.find((r) => !r.cargoTypeId) ??
bulkRates[0] ??
null;
if (bulkRate) {
lineItems.push({
code: 'BULK_FREIGHT',

View File

@@ -0,0 +1,81 @@
import { Readable } from 'stream';
import { ContractTransitionService } from './contract-transition.service';
/**
* A stamp may be uploaded as JPEG/WebP while a drawn signature is always PNG.
* The type must survive the round-trip: data URL in → stored object extension
* → data URL out. Getting this wrong labels JPEG bytes as image/png in the
* contract PDF and leaves the seal to browser content-sniffing.
*/
describe('ContractTransitionService signature/stamp asset typing', () => {
const pngPixel =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
const jpegPixel = `data:image/jpeg;base64,${Buffer.from('fake-jpeg').toString('base64')}`;
/** Minimal service instance — only filesService/minioService are exercised. */
const build = () => {
const uploaded: Array<{ code: string; mimetype: string; name: string }> = [];
const filesService = {
upsertByCode: jest.fn(({ code, file }) => {
uploaded.push({ code, mimetype: file.mimetype, name: file.originalname });
return Promise.resolve({ id: `file-${code}`, url: `https://minio/x/${file.originalname}` });
}),
};
const minioService = {
getObjectNameFromUrl: (url: string) => url.split('/').pop() ?? '',
getFileStream: () => Promise.resolve(Readable.from(Buffer.from('bytes'))),
};
const service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, { filesService, minioService });
return { service, uploaded };
};
const contract = { id: 'c-1', reference: 'CTR-2026-00001' };
it('stores a drawn PNG signature as image/png', async () => {
const { service, uploaded } = build();
await (service as never as {
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
}).uploadSignatureAsset(contract, 'signature_customer', pngPixel);
expect(uploaded[0].mimetype).toBe('image/png');
expect(uploaded[0].name).toBe('signature-customer-CTR-2026-00001.png');
});
it('keeps an uploaded JPEG stamp as image/jpeg, not image/png', async () => {
const { service, uploaded } = build();
await (service as never as {
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
}).uploadSignatureAsset(contract, 'stamp_customer', jpegPixel);
expect(uploaded[0].mimetype).toBe('image/jpeg');
expect(uploaded[0].name).toBe('stamp-customer-CTR-2026-00001.jpg');
});
it('inlines a stored .jpg back as a data:image/jpeg URI', async () => {
const { service } = build();
const inline = (service as never as {
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
}).inlineImageUrl.bind(service);
await expect(inline('https://minio/x/stamp-customer-CTR.jpg')).resolves.toMatch(
/^data:image\/jpeg;base64,/,
);
await expect(inline('https://minio/x/signature-customer-CTR.png')).resolves.toMatch(
/^data:image\/png;base64,/,
);
});
it('passes through empty and already-inlined values untouched', async () => {
const { service } = build();
const inline = (service as never as {
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
}).inlineImageUrl.bind(service);
await expect(inline(null)).resolves.toBeNull();
await expect(inline(pngPixel)).resolves.toBe(pngPixel);
});
});

View File

@@ -928,23 +928,41 @@ export class ContractTransitionService {
});
}
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
/**
* Replace MinIO signature/stamp URLs with inline data URIs so they render in
* the PDF — Chromium cannot fetch the private bucket.
*/
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
signatures: Array<{
signatureImageUrl?: string | null;
stampImageUrl?: string | null;
}>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
/* keep original url */
}
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
}
}
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
private async inlineImageUrl(
url?: string | null,
): Promise<string | null | undefined> {
if (!url || 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);
const extension = objectName.split('.').pop()?.toLowerCase();
const mime =
extension === 'jpg' || extension === 'jpeg'
? 'image/jpeg'
: extension === 'webp'
? 'image/webp'
: 'image/png';
return `data:${mime};base64,${buffer.toString('base64')}`;
} catch {
return url;
}
}
@@ -959,6 +977,46 @@ export class ContractTransitionService {
});
}
/**
* base64 (data URL or raw) → image FileRecord stored on the contract under
* `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
* WebP, so the type is read off the data-URL prefix rather than assumed —
* the stored extension is what {@link inlineImageUrl} reads it back as.
*/
private async uploadSignatureAsset(
contract: Contract,
code: string,
imageBase64: string,
): Promise<FileRecord> {
const mimetype =
/^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
'image/png';
const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const file: Express.Multer.File = {
fieldname: code,
originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
encoding: '7bit',
mimetype,
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code,
file,
});
}
/** Apply a digital signature row (mirrors booking-contract.service). */
private async applySignature(
contract: Contract,
@@ -985,29 +1043,28 @@ export class ContractTransitionService {
);
}
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
// The company stamp is a separate image from the drawn signature. Both
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
// are internal approval signatures, not party seals, so they stay exempt.
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
if (stampRequired && !dto.stampImageBase64) {
throw new BadRequestException(
'A company stamp is required to sign this contract.',
);
}
const fileRecord = await this.filesService.upsertByCode({
resourceId: contract.id,
resource: 'contracts',
code: `signature_${role.toLowerCase()}`,
file: sigFile,
});
const fileRecord = await this.uploadSignatureAsset(
contract,
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = dto.stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1015,6 +1072,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
consentText: dto.consentText ?? null,
});
@@ -1127,6 +1185,19 @@ export class ContractTransitionService {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
// Both parties' stamps must be on file before the contract executes. The
// EDR stamp is enforced by applySignature below; the customer's is checked
// here so a contract signed before stamps existed can't slip through.
const customerSignature = await this.contractsRepository.findSignature(
contractId,
'CUSTOMER',
);
if (!customerSignature?.stampFileId) {
throw new BadRequestException(
'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
);
}
await this.applySignature(contract, dto, options);
const now = new Date();

View File

@@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
import { ContractsRepository } from './contracts.repository';
import { ContractExpiryService } from './contract-expiry.service';
import { ContractPricingService } from './contract-pricing.service';
import { ContractNotifierService } from './contract-notifier.service';
import { ContractTransitionService } from './contract-transition.service';
@@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
providers: [
ContractsService,
ContractsRepository,
ContractExpiryService,
ContractPricingService,
ContractNotifierService,
ContractTransitionService,

View File

@@ -14,6 +14,7 @@ import {
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
export interface ContractListFilterOptions {
statuses?: string[];
@@ -66,6 +67,46 @@ export class ContractsRepository extends BaseRepository<Contract> {
return Number(row?.max ?? 0);
}
/**
* Non-terminal contracts for the same company + service type, with routes
* loaded — candidates for the duplicate-contract check on create(). Terminal
* filtering happens in JS via isEffectivelyExpired (also covers the
* date-passed-but-not-yet-cron-flipped case).
*/
async findDuplicateCandidates(
companyId: string,
serviceTypeId: string,
): Promise<Contract[]> {
return this.repository
.createQueryBuilder('contract')
.leftJoinAndSelect('contract.routes', 'routes')
.where('contract.deleted_at IS NULL')
.andWhere('contract.company_id = :companyId', { companyId })
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
.andWhere('contract.status NOT IN (:...terminal)', {
terminal: TERMINAL_CONTRACT_STATUSES,
})
.getMany();
}
/**
* Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the
* number of rows updated (for cron logging).
*/
async expireLapsedContracts(): Promise<number> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.where('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
now: new Date(),
})
.execute();
return result.affected ?? 0;
}
/** Find a contract by ID with all child collections, service type, company and files. */
async findByIdWithRelations(id: string): Promise<Contract | null> {
if (!id) return null;
@@ -434,7 +475,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
findSignatures(contractId: string): Promise<ContractSignature[]> {
return this.dataSource.getRepository(ContractSignature).find({
where: { contractId },
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
order: { signedAt: 'ASC' },
});
}
@@ -445,7 +486,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
): Promise<ContractSignature | null> {
return this.dataSource.getRepository(ContractSignature).findOne({
where: { contractId, role },
relations: ['signatureFile'],
relations: ['signatureFile', 'stampFile'],
});
}

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
@@ -24,6 +25,7 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
@@ -155,6 +157,42 @@ export class ContractsService {
}
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
),
),
);
if (duplicate) {
const until = duplicate.contractValidUntil
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
);
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create(
dto: CreateContractDto,
@@ -186,6 +224,9 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
}
// Stamp the operational profile for portal scoping. A forwarder contract
// pins its profile explicitly (trade direction can't tell it apart from a

View File

@@ -17,6 +17,17 @@ export class SignContractDto {
@MinLength(20)
signatureImageBase64?: string;
@ApiPropertyOptional({
description:
'PNG company stamp/seal image as base64 (with or without data URL prefix). ' +
'Required for the CUSTOMER and STAFF roles — both parties must seal the ' +
'contract before it is fully executed.',
})
@IsOptional()
@IsString()
@MinLength(20)
stampImageBase64?: string;
@ApiProperty()
@IsString()
@MinLength(1)

View File

@@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity {
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
/** Company stamp/seal image, uploaded alongside the drawn signature. */
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
stampFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'stamp_file_id' })
stampFile?: FileRecord | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;

View File

@@ -0,0 +1,25 @@
import type { Contract } from '../entities/contract.entity';
/** Statuses that already mean "done/void" — a contract in one of these never blocks a duplicate. */
export const TERMINAL_CONTRACT_STATUSES = [
'REJECTED',
'CANCELLED',
'CONTRACT_CLOSED',
'ARCHIVED',
'EXPIRED',
] as const;
/**
* True once a contract is done, either explicitly (terminal status) or by date
* (past contractValidUntil). Checked by date too because the nightly expiry
* cron only flips the status once a day — this keeps same-day checks correct
* even a few hours before the cron runs.
*/
export function isEffectivelyExpired(
contract: Pick<Contract, 'status' | 'contractValidUntil'>,
): boolean {
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
return true;
}
return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date());
}