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

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