refactor(f4): remove Director report review service
This commit is contained in:
@@ -1,433 +0,0 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { isAbsolute, parse, resolve } from 'node:path';
|
||||
import { ConflictException, ForbiddenException, 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 } from '../database/entities';
|
||||
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
|
||||
import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
|
||||
import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
|
||||
import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
|
||||
import {
|
||||
INSPECTION_REPORT_WORD_MIME,
|
||||
inspectInspectionReportRevisionUpload,
|
||||
type UploadedInspectionReportRevisionFile,
|
||||
validateInspectionReportRevisionContainer,
|
||||
} from './inspection-report-revision-file';
|
||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||
|
||||
export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
|
||||
export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD';
|
||||
|
||||
export interface InspectionReportReviewPerson {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export interface InspectionReportRevisionView {
|
||||
id: string;
|
||||
reportId: string;
|
||||
revisionNumber: number;
|
||||
source: InspectionReportRevisionSource;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
changeSummary: string | null;
|
||||
createdAt: Date;
|
||||
createdBy: InspectionReportReviewPerson;
|
||||
}
|
||||
|
||||
export interface InspectionReportSignatureView {
|
||||
id: string;
|
||||
revisionId: string;
|
||||
signedAt: Date;
|
||||
confirmationText: string;
|
||||
signatureSha256: string;
|
||||
signedBy: InspectionReportReviewPerson;
|
||||
}
|
||||
|
||||
export interface InspectionReportReviewView {
|
||||
reportId: string;
|
||||
reportCode: string;
|
||||
reviewStatus: InspectionReportReviewStatus;
|
||||
currentRevisionNumber: number;
|
||||
approvedRevisionId: string | null;
|
||||
approvedAt: Date | null;
|
||||
reviewNote: string | null;
|
||||
approvedBy: InspectionReportReviewPerson | null;
|
||||
signature: InspectionReportSignatureView | null;
|
||||
revisions: InspectionReportRevisionView[];
|
||||
}
|
||||
|
||||
interface LockedReport {
|
||||
id: string;
|
||||
code: string;
|
||||
status: string;
|
||||
frozenSha256: string;
|
||||
reviewStatus: InspectionReportReviewStatus;
|
||||
currentRevisionNumber: number;
|
||||
approvedRevisionId: string | null;
|
||||
approvedAt: Date | null;
|
||||
reviewNote: string | null;
|
||||
}
|
||||
|
||||
interface StoredRevisionRow {
|
||||
id: string;
|
||||
reportId: string;
|
||||
revisionNumber: number;
|
||||
source: InspectionReportRevisionSource;
|
||||
originalName: string;
|
||||
storedName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.';
|
||||
|
||||
@Injectable()
|
||||
export class InspectionReportReviewService {
|
||||
private readonly revisionRoot: string;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly audit: AuditService,
|
||||
private readonly word: InspectionReportWordService,
|
||||
config: ConfigService,
|
||||
) {
|
||||
const configured = config.get<string>('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions';
|
||||
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path');
|
||||
this.revisionRoot = resolve(configured);
|
||||
if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root');
|
||||
}
|
||||
|
||||
async get(reportId: string): Promise<InspectionReportReviewView> {
|
||||
await this.word.ensure(reportId);
|
||||
await this.ensureAutomaticRevision(reportId);
|
||||
return this.loadView(reportId);
|
||||
}
|
||||
|
||||
async createRevision(
|
||||
reportId: string,
|
||||
dto: CreateInspectionReportRevisionDto,
|
||||
file: UploadedInspectionReportRevisionFile | undefined,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionReportReviewView> {
|
||||
inspectInspectionReportRevisionUpload(file);
|
||||
await this.assertDirector(principal.userId, this.dataSource.manager);
|
||||
await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 });
|
||||
const storedName = `${reportId}-${randomUUID()}.docx`;
|
||||
const filePath = resolve(this.revisionRoot, storedName);
|
||||
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
|
||||
await writeFile(filePath, file!.buffer, { mode: 0o600 });
|
||||
try {
|
||||
await validateInspectionReportRevisionContainer(filePath);
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
const created = await this.dataSource.transaction(async (manager) => {
|
||||
const report = await this.lockReport(manager, reportId);
|
||||
this.assertOpenForReview(report);
|
||||
const nextRevision = report.currentRevisionNumber + 1;
|
||||
const [row] = await manager.query(`
|
||||
INSERT INTO inspection_report_revisions (
|
||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
||||
size_bytes, sha256, change_summary, created_by
|
||||
) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9)
|
||||
RETURNING id
|
||||
`, [
|
||||
report.id,
|
||||
nextRevision,
|
||||
this.cleanOriginalName(file!.originalname),
|
||||
storedName,
|
||||
INSPECTION_REPORT_WORD_MIME,
|
||||
file!.buffer.length,
|
||||
sha256,
|
||||
dto.changeSummary.trim(),
|
||||
principal.userId,
|
||||
]) as Array<{ id: string }>;
|
||||
await manager.query(`
|
||||
UPDATE inspection_reports
|
||||
SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [report.id, nextRevision]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_REPORT_REVISION_ADDED,
|
||||
entityType: 'inspection_report_revision',
|
||||
entityId: row.id,
|
||||
afterData: {
|
||||
reportId: report.id,
|
||||
reportCode: report.code,
|
||||
revisionNumber: nextRevision,
|
||||
sha256,
|
||||
source: 'DIRECTOR_UPLOAD',
|
||||
},
|
||||
}, manager);
|
||||
return row.id;
|
||||
});
|
||||
if (!created) throw this.storageError();
|
||||
} catch (error) {
|
||||
await rm(filePath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
return this.loadView(reportId);
|
||||
}
|
||||
|
||||
async approve(
|
||||
reportId: string,
|
||||
dto: ApproveInspectionReportDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionReportReviewView> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDirector(principal.userId, manager);
|
||||
const report = await this.lockReport(manager, reportId);
|
||||
this.assertOpenForReview(report);
|
||||
const [revision] = await manager.query(`
|
||||
SELECT id, revision_number AS "revisionNumber", sha256
|
||||
FROM inspection_report_revisions
|
||||
WHERE report_id = $1 AND revision_number = $2
|
||||
`, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
|
||||
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' });
|
||||
const approvedAt = new Date();
|
||||
await manager.query(`
|
||||
UPDATE inspection_reports
|
||||
SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3,
|
||||
approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_REPORT_APPROVED,
|
||||
entityType: 'inspection_report',
|
||||
entityId: report.id,
|
||||
afterData: {
|
||||
reportCode: report.code,
|
||||
revisionId: revision.id,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
revisionSha256: revision.sha256,
|
||||
approvedAt: approvedAt.toISOString(),
|
||||
},
|
||||
}, manager);
|
||||
});
|
||||
return this.loadView(reportId);
|
||||
}
|
||||
|
||||
async signFinal(
|
||||
reportId: string,
|
||||
dto: SignFinalInspectionReportDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<InspectionReportReviewView> {
|
||||
if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' });
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDirector(principal.userId, manager);
|
||||
const report = await this.lockReport(manager, reportId);
|
||||
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' });
|
||||
if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) {
|
||||
throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' });
|
||||
}
|
||||
const [revision] = await manager.query(`
|
||||
SELECT id, revision_number AS "revisionNumber", sha256
|
||||
FROM inspection_report_revisions
|
||||
WHERE id = $1 AND report_id = $2
|
||||
`, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
|
||||
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' });
|
||||
const signedAt = new Date();
|
||||
const payload = {
|
||||
schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1',
|
||||
reportId: report.id,
|
||||
reportCode: report.code,
|
||||
reportFrozenSha256: report.frozenSha256,
|
||||
revisionId: revision.id,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
revisionSha256: revision.sha256,
|
||||
signedBy: principal.userId,
|
||||
signedByUsername: principal.username,
|
||||
signedAt: signedAt.toISOString(),
|
||||
confirmationText: FINAL_CONFIRMATION_TEXT,
|
||||
};
|
||||
const signatureSha256 = sha256CanonicalJson(payload);
|
||||
const [signature] = await manager.query(`
|
||||
INSERT INTO inspection_report_signatures (
|
||||
report_id, revision_id, signed_by, signed_at, confirmation_text,
|
||||
signature_payload, signature_sha256
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING id
|
||||
`, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>;
|
||||
await manager.query(`
|
||||
UPDATE inspection_reports
|
||||
SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [report.id, signedAt]);
|
||||
await this.audit.record({
|
||||
...administrationAuditContext(principal, request),
|
||||
action: AuditAction.INSPECTION_REPORT_SIGNED,
|
||||
entityType: 'inspection_report_signature',
|
||||
entityId: signature.id,
|
||||
afterData: {
|
||||
reportId: report.id,
|
||||
reportCode: report.code,
|
||||
revisionId: revision.id,
|
||||
revisionNumber: revision.revisionNumber,
|
||||
revisionSha256: revision.sha256,
|
||||
signatureSha256,
|
||||
signedAt: signedAt.toISOString(),
|
||||
},
|
||||
}, manager);
|
||||
});
|
||||
return this.loadView(reportId);
|
||||
}
|
||||
|
||||
async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
|
||||
const [revision] = await this.dataSource.query(`
|
||||
SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source,
|
||||
original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType",
|
||||
size_bytes::integer AS "sizeBytes", sha256
|
||||
FROM inspection_report_revisions WHERE id = $1
|
||||
`, [revisionId]) as StoredRevisionRow[];
|
||||
if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' });
|
||||
if (revision.source === 'AUTO') return this.word.content(revision.reportId);
|
||||
const filePath = resolve(this.revisionRoot, revision.storedName);
|
||||
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError();
|
||||
const buffer = await readFile(filePath);
|
||||
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
||||
if (sha256 !== revision.sha256) throw this.storageError();
|
||||
return { filePath, originalName: revision.originalName, mimeType: revision.mimeType };
|
||||
}
|
||||
|
||||
private async ensureAutomaticRevision(reportId: string): Promise<void> {
|
||||
await this.dataSource.query(`
|
||||
INSERT INTO inspection_report_revisions (
|
||||
report_id, revision_number, source, original_name, stored_name, mime_type,
|
||||
size_bytes, sha256, change_summary, created_by, created_at
|
||||
)
|
||||
SELECT
|
||||
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
|
||||
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
|
||||
COALESCE(word_generated_at, generated_at)
|
||||
FROM inspection_reports
|
||||
WHERE id = $1
|
||||
AND word_status = 'READY'
|
||||
AND word_original_name IS NOT NULL
|
||||
AND word_stored_name IS NOT NULL
|
||||
AND word_mime_type = $2
|
||||
AND word_size_bytes > 0
|
||||
AND word_sha256 ~ '^[0-9a-f]{64}$'
|
||||
ON CONFLICT (report_id, revision_number) DO NOTHING
|
||||
`, [reportId, INSPECTION_REPORT_WORD_MIME]);
|
||||
await this.dataSource.query(`
|
||||
UPDATE inspection_reports
|
||||
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1)
|
||||
`, [reportId]);
|
||||
}
|
||||
|
||||
private async loadView(reportId: string): Promise<InspectionReportReviewView> {
|
||||
const [report] = await this.dataSource.query(`
|
||||
SELECT
|
||||
report.id AS "reportId",
|
||||
report.code AS "reportCode",
|
||||
report.review_status AS "reviewStatus",
|
||||
report.current_revision_number AS "currentRevisionNumber",
|
||||
report.approved_revision_id AS "approvedRevisionId",
|
||||
report.approved_at AS "approvedAt",
|
||||
report.review_note AS "reviewNote",
|
||||
CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||
'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name
|
||||
) END AS "approvedBy"
|
||||
FROM inspection_reports report
|
||||
LEFT JOIN users approver ON approver.id = report.approved_by
|
||||
WHERE report.id = $1
|
||||
`, [reportId]) as Array<Omit<InspectionReportReviewView, 'signature' | 'revisions'>>;
|
||||
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
||||
const revisions = await this.dataSource.query(`
|
||||
SELECT
|
||||
revision.id,
|
||||
revision.report_id AS "reportId",
|
||||
revision.revision_number AS "revisionNumber",
|
||||
revision.source,
|
||||
revision.original_name AS "originalName",
|
||||
revision.mime_type AS "mimeType",
|
||||
revision.size_bytes::integer AS "sizeBytes",
|
||||
revision.sha256,
|
||||
revision.change_summary AS "changeSummary",
|
||||
revision.created_at AS "createdAt",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name
|
||||
) AS "createdBy"
|
||||
FROM inspection_report_revisions revision
|
||||
JOIN users creator ON creator.id = revision.created_by
|
||||
WHERE revision.report_id = $1
|
||||
ORDER BY revision.revision_number DESC
|
||||
`, [reportId]) as InspectionReportRevisionView[];
|
||||
const [signature] = await this.dataSource.query(`
|
||||
SELECT
|
||||
signature.id,
|
||||
signature.revision_id AS "revisionId",
|
||||
signature.signed_at AS "signedAt",
|
||||
signature.confirmation_text AS "confirmationText",
|
||||
signature.signature_sha256 AS "signatureSha256",
|
||||
JSONB_BUILD_OBJECT(
|
||||
'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name
|
||||
) AS "signedBy"
|
||||
FROM inspection_report_signatures signature
|
||||
JOIN users signer ON signer.id = signature.signed_by
|
||||
WHERE signature.report_id = $1
|
||||
`, [reportId]) as InspectionReportSignatureView[];
|
||||
return { ...report, signature: signature ?? null, revisions };
|
||||
}
|
||||
|
||||
private async lockReport(manager: EntityManager, reportId: string): Promise<LockedReport> {
|
||||
const [report] = await manager.query(`
|
||||
SELECT
|
||||
id, code, status, frozen_sha256 AS "frozenSha256",
|
||||
review_status AS "reviewStatus",
|
||||
current_revision_number AS "currentRevisionNumber",
|
||||
approved_revision_id AS "approvedRevisionId",
|
||||
approved_at AS "approvedAt",
|
||||
review_note AS "reviewNote"
|
||||
FROM inspection_reports
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [reportId]) as LockedReport[];
|
||||
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
|
||||
if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' });
|
||||
return report;
|
||||
}
|
||||
|
||||
private assertOpenForReview(report: LockedReport): void {
|
||||
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' });
|
||||
if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' });
|
||||
}
|
||||
|
||||
private async assertDirector(userId: string, manager: EntityManager): Promise<void> {
|
||||
const [role] = await manager.query(`
|
||||
SELECT role.code
|
||||
FROM user_roles membership
|
||||
JOIN roles role ON role.id = membership.role_id
|
||||
WHERE membership.user_id = $1 AND role.code = 'director'
|
||||
LIMIT 1
|
||||
`, [userId]) as Array<{ code: string }>;
|
||||
if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' });
|
||||
}
|
||||
|
||||
private cleanOriginalName(value: string): string {
|
||||
const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim();
|
||||
return (cleaned || 'informe-corregido.docx').slice(0, 255);
|
||||
}
|
||||
|
||||
private storageError(): InternalServerErrorException {
|
||||
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user