mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user