Files
dh-inspeccion-v2/api-v3/src/inspection-reports/inspection-report-workflow.service.ts
T

429 lines
16 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionReportStatus,
} from '../database/entities';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
export interface UploadedInspectionReportFile {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
}
interface ReportRow {
id: string;
actId: string;
visitId: string;
code: string;
status: InspectionReportStatus;
executiveSummary: string | null;
reportDescription: string | null;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
@Injectable()
export class InspectionReportWorkflowService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
?? '/app/storage/asset-media/inspection-reports';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root');
}
}
async updateNarrative(
reportId: string,
dto: UpdateInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO',
});
}
const before = {
executiveSummary: report.executiveSummary,
reportDescription: report.reportDescription,
};
const executiveSummary = dto.executiveSummary !== undefined
? dto.executiveSummary
: report.executiveSummary;
const reportDescription = dto.description !== undefined
? dto.description
: report.reportDescription;
await manager.query(`
UPDATE inspection_reports
SET executive_summary=$2,
report_description=$3,
word_status=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN 'PENDING' ELSE word_status END,
word_original_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_original_name END,
word_stored_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_stored_name END,
word_mime_type=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_mime_type END,
word_size_bytes=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_size_bytes END,
word_sha256=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_sha256 END,
word_generated_at=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_generated_at END,
word_error=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_error END,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [reportId, executiveSummary, reportDescription]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_UPDATED,
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: { executiveSummary, reportDescription },
metadata: { reportCode: report.code, actId: report.actId },
}, manager);
return this.getWorkflowView(manager, reportId);
});
}
async officialize(
reportId: string,
dto: OfficializeInspectionReportDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertPdf(file);
const id = randomUUID();
const storedName = `gedo-${id}.pdf`;
const filePath = resolve(this.root, storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El IF oficial de GEDO ya fue registrado y es inmutable',
});
}
const officializedAt = new Date(dto.gedoOfficializedAt);
const now = new Date();
if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INVALID_GEDO_DATE',
message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro',
});
}
await manager.query(`
UPDATE inspection_reports
SET status='OFFICIALIZED',
gedo_if_identifier=$2,
gedo_officialized_at=$3,
gedo_pdf_original_name=$4,
gedo_pdf_stored_name=$5,
gedo_pdf_mime_type='application/pdf',
gedo_pdf_size_bytes=$6,
gedo_pdf_sha256=$7,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [
reportId,
dto.gedoIfIdentifier,
officializedAt,
file!.originalname,
storedName,
file!.buffer.length,
sha256,
]);
// GEDO oficializa el INF, pero no equivale por sí solo a la notificación
// administrativa de un Acta no urgente. El vencimiento queda pendiente
// hasta que el procedimiento defina y registre el evento de notificación.
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_OFFICIALIZED,
entityType: 'inspection_report',
entityId: reportId,
afterData: {
status: InspectionReportStatus.OFFICIALIZED,
gedoIfIdentifier: dto.gedoIfIdentifier,
gedoOfficializedAt: officializedAt,
gedoPdfOriginalName: file!.originalname,
gedoPdfSha256: sha256,
},
metadata: {
reportCode: report.code,
actId: report.actId,
immutable: true,
deadlineActivation: 'PENDING_NOTIFICATION_EVENT',
},
}, manager);
return this.getWorkflowView(manager, reportId);
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT
gedo_pdf_original_name AS "originalName",
gedo_pdf_stored_name AS "storedName",
gedo_pdf_mime_type AS "mimeType",
gedo_pdf_size_bytes::integer AS "sizeBytes",
gedo_pdf_sha256 AS sha256
FROM inspection_reports
WHERE id=$1
`, [reportId]) as Array<{
originalName: string | null;
storedName: string | null;
mimeType: string | null;
sizeBytes: number | null;
sha256: string | null;
}>;
if (!row) throw reportNotFound();
if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',
message: 'El PDF oficial de GEDO todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.sha256) throw this.reportStorageError();
return {
filePath,
originalName: row.originalName,
mimeType: row.mimeType ?? 'application/pdf',
};
}
async addFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FILE_TOO_LARGE',
message: 'El archivo supera el máximo permitido de 40 MB',
});
}
const followUpId = randomUUID();
const ext = this.safeExtension(file?.originalname);
const storedName = file ? `followup-${followUpId}${ext}` : null;
const filePath = storedName ? resolve(this.root, storedName) : null;
const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null;
if (filePath && file) {
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 });
}
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
const occurredAt = new Date(dto.occurredAt);
await manager.query(`
INSERT INTO inspection_report_follow_ups (
id,report_id,type,external_reference,occurred_at,description,
original_name,stored_name,mime_type,size_bytes,sha256,created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, [
followUpId,
reportId,
dto.type,
dto.externalReference ?? null,
occurredAt,
dto.description ?? null,
file?.originalname ?? null,
storedName,
file?.mimetype ?? null,
file?.buffer.length ?? null,
sha256,
principal.userId,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED,
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
type: dto.type,
externalReference: dto.externalReference ?? null,
occurredAt,
description: dto.description ?? null,
originalName: file?.originalname ?? null,
sha256,
},
metadata: { reportCode: report.code, actId: report.actId, appendOnly: true },
}, manager);
return this.listFollowUpsWithManager(manager, reportId);
});
} catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined);
throw error;
}
}
async listFollowUps(reportId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.listFollowUpsWithManager(this.dataSource.manager, reportId);
}
private async listFollowUpsWithManager(manager: EntityManager, reportId: string) {
return manager.query(`
SELECT
follow_up.id,
follow_up.report_id AS "reportId",
follow_up.type,
follow_up.external_reference AS "externalReference",
follow_up.occurred_at AS "occurredAt",
follow_up.description,
follow_up.original_name AS "originalName",
follow_up.mime_type AS "mimeType",
follow_up.size_bytes AS "sizeBytes",
follow_up.sha256,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt"
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id=$1
ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id
`, [reportId]);
}
private reportStorageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',
message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',
});
}
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
if (!file?.buffer?.length) {
throw new BadRequestException({
code: 'GEDO_PDF_REQUIRED',
message: 'Debe adjuntarse el PDF oficial generado por GEDO',
});
}
if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'GEDO_PDF_TOO_LARGE',
message: 'El PDF oficial supera el máximo permitido de 40 MB',
});
}
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INVALID_GEDO_PDF',
message: 'El documento oficial de GEDO debe ser un PDF válido',
});
}
}
private safeExtension(name: string | undefined): string {
if (!name) return '';
const dot = name.lastIndexOf('.');
if (dot < 0) return '';
const ext = name.slice(dot).toLowerCase();
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1 FOR UPDATE
`, [id]) as ReportRow[];
if (!row) throw reportNotFound();
return row;
}
private async getReport(manager: EntityManager, id: string): Promise<ReportRow | null> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1
`, [id]) as ReportRow[];
return row ?? null;
}
private async getWorkflowView(manager: EntityManager, id: string) {
const report = await this.getReport(manager, id);
if (!report) throw reportNotFound();
const [act] = await manager.query(`
SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType",
deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt"
FROM inspection_acts WHERE id=$1
`, [report.actId]);
return {
...report,
act,
followUps: await this.listFollowUpsWithManager(manager, id),
};
}
}