mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
add reusable per-user contract signatures with profile prefill and approval
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user