429 lines
16 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|