chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
function canonicalValue(value: unknown): unknown {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) return value.map(canonicalValue);
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, canonicalValue(item)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function canonicalJson(value: unknown): string {
|
||||
return JSON.stringify(canonicalValue(value));
|
||||
}
|
||||
|
||||
export function sha256CanonicalJson(value: unknown): string {
|
||||
return createHash('sha256').update(canonicalJson(value)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsEnum, IsISO8601 } from 'class-validator';
|
||||
import { InspectionActUploadMode } from '../../database/entities';
|
||||
|
||||
export class CloseInspectionActDto {
|
||||
@IsISO8601({ strict: true })
|
||||
clientClosedAt!: string;
|
||||
|
||||
@IsEnum(InspectionActUploadMode)
|
||||
uploadMode!: InspectionActUploadMode;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { InspectionActSignatureStatus } from '../../database/entities';
|
||||
|
||||
export class CreateCompanyOutcomeDto {
|
||||
@IsIn([
|
||||
InspectionActSignatureStatus.REFUSED,
|
||||
InspectionActSignatureStatus.ABSENT,
|
||||
])
|
||||
status!: InspectionActSignatureStatus.REFUSED | InspectionActSignatureStatus.ABSENT;
|
||||
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(4000)
|
||||
reason!: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
Equals,
|
||||
IsBoolean,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateInspectionSignatureDto {
|
||||
@Transform(({ value }) => value === true || value === 'true')
|
||||
@IsBoolean()
|
||||
@Equals(true)
|
||||
consentAccepted!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
clientSignedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
longitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 3 })
|
||||
@Min(0)
|
||||
@Max(100000)
|
||||
accuracyM?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
deviceLabel?: string;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
InspectionResponsibleAttendanceStatus,
|
||||
InspectionResponsibleDocumentType,
|
||||
} from '../../database/entities';
|
||||
|
||||
const trimOrUndefined = ({ value }: { value: unknown }) => (
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
);
|
||||
|
||||
export class UpsertInspectionResponsibleDto {
|
||||
@IsEnum(InspectionResponsibleAttendanceStatus)
|
||||
attendanceStatus!: InspectionResponsibleAttendanceStatus;
|
||||
|
||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
|
||||
))
|
||||
@Transform(trimOrUndefined)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
fullName?: string;
|
||||
|
||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
|
||||
))
|
||||
@IsEnum(InspectionResponsibleDocumentType)
|
||||
documentType?: InspectionResponsibleDocumentType;
|
||||
|
||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
|
||||
))
|
||||
@Transform(trimOrUndefined)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(40)
|
||||
documentNumber?: string;
|
||||
|
||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.PRESENT
|
||||
))
|
||||
@Transform(trimOrUndefined)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
position?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(trimOrUndefined)
|
||||
@IsEmail()
|
||||
@MaxLength(320)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(trimOrUndefined)
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
phone?: string;
|
||||
|
||||
@ValidateIf((dto: UpsertInspectionResponsibleDto) => (
|
||||
dto.attendanceStatus === InspectionResponsibleAttendanceStatus.ABSENT
|
||||
))
|
||||
@Transform(trimOrUndefined)
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(4000)
|
||||
absenceReason?: string;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { CloseInspectionActDto } from './dto/close-inspection-act.dto';
|
||||
import { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto';
|
||||
import { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto';
|
||||
import { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto';
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
import {
|
||||
MAX_INSPECTION_SIGNATURE_BYTES,
|
||||
type UploadedInspectionSignatureFile,
|
||||
} from './inspection-signature-file';
|
||||
|
||||
@Controller('inspection-acts/:actId')
|
||||
export class InspectionClosingController {
|
||||
constructor(private readonly closing: InspectionClosingService) {}
|
||||
|
||||
@Get('closure')
|
||||
@RequirePermissions('inspection_closure.read')
|
||||
get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) {
|
||||
return this.closing.get(actId);
|
||||
}
|
||||
|
||||
@Put('responsible')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
responsible(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: UpsertInspectionResponsibleDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.upsertResponsible(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('ready')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
ready(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.prepare(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('reopen')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
reopen(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.reopen(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('signatures/inspector')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
|
||||
}))
|
||||
inspectorSignature(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CreateInspectionSignatureDto,
|
||||
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.signInspector(actId, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Post('signatures/company')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
|
||||
}))
|
||||
companySignature(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CreateInspectionSignatureDto,
|
||||
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.signCompany(actId, dto, file, principal, request);
|
||||
}
|
||||
|
||||
@Post('company-outcome')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
companyOutcome(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CreateCompanyOutcomeDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('close')
|
||||
@RequirePermissions('inspection_closure.close')
|
||||
close(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: CloseInspectionActDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.close(actId, dto, principal, request);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('inspection-act-signatures')
|
||||
export class InspectionSignatureContentController {
|
||||
constructor(private readonly closing: InspectionClosingService) {}
|
||||
|
||||
@Get(':signatureId/content')
|
||||
@RequirePermissions('inspection_closure.read')
|
||||
async content(
|
||||
@Param('signatureId', new ParseUUIDPipe({ version: '4' })) signatureId: string,
|
||||
@Res() response: Response,
|
||||
): Promise<void> {
|
||||
const { filePath, signature } = await this.closing.signatureContent(signatureId);
|
||||
response.setHeader('Content-Type', 'image/png');
|
||||
response.setHeader('Content-Length', String(signature.sizeBytes));
|
||||
response.setHeader('Content-Disposition', 'inline; filename="firma.png"');
|
||||
response.setHeader('Cache-Control', 'private, no-store');
|
||||
response.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'");
|
||||
await new Promise<void>((resolveSend, rejectSend) => {
|
||||
response.sendFile(filePath, (error) => {
|
||||
if (error) rejectSend(error);
|
||||
else resolveSend();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
} from './inspection-closing.controller';
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
||||
providers: [InspectionClosingService],
|
||||
})
|
||||
export class InspectionClosingModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const MAX_INSPECTION_SIGNATURE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface UploadedInspectionSignatureFile {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface InspectedInspectionSignatureFile {
|
||||
originalName: string;
|
||||
mimeType: 'image/png';
|
||||
extension: '.png';
|
||||
}
|
||||
|
||||
export function inspectInspectionSignatureFile(
|
||||
file: UploadedInspectionSignatureFile | undefined,
|
||||
): InspectedInspectionSignatureFile {
|
||||
if (!file?.buffer?.length) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_SIGNATURE_FILE_REQUIRED',
|
||||
message: 'La firma manuscrita en formato PNG es obligatoria',
|
||||
});
|
||||
}
|
||||
if (file.buffer.length > MAX_INSPECTION_SIGNATURE_BYTES) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_SIGNATURE_FILE_TOO_LARGE',
|
||||
message: 'La firma no puede superar 1 MB',
|
||||
});
|
||||
}
|
||||
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
if (
|
||||
file.mimetype !== 'image/png'
|
||||
|| file.buffer.length < pngSignature.length
|
||||
|| !file.buffer.subarray(0, pngSignature.length).equals(pngSignature)
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_SIGNATURE_PNG_REQUIRED',
|
||||
message: 'La firma debe ser una imagen PNG válida',
|
||||
});
|
||||
}
|
||||
return {
|
||||
originalName: (file.originalname || 'firma.png').slice(0, 255),
|
||||
mimeType: 'image/png',
|
||||
extension: '.png',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user