F4.3 · dossier INF, GEDO IF y seguimiento

This commit is contained in:
2026-09-07 19:55:04 -03:00
parent 367c7df45a
commit fbe8f2e8cf
10 changed files with 915 additions and 6 deletions
@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhaseF4ReportDossierPermissions1790042400000 implements MigrationInterface {
name = 'PhaseF4ReportDossierPermissions1790042400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
INSERT INTO permissions (code, description)
VALUES
('inspection_reports.edit', 'Editar el contenido de trabajo del Informe de inspección'),
('inspection_reports.officialize', 'Registrar el IF y PDF oficial generado por GEDO'),
('inspection_reports.follow_up', 'Agregar respuestas, notas y documentos al seguimiento del Informe')
ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (
VALUES
('admin', 'inspection_reports.edit'),
('admin', 'inspection_reports.officialize'),
('admin', 'inspection_reports.follow_up'),
('supervisor', 'inspection_reports.edit'),
('supervisor', 'inspection_reports.officialize'),
('supervisor', 'inspection_reports.follow_up'),
('inspector', 'inspection_reports.edit'),
('inspector', 'inspection_reports.officialize'),
('inspector', 'inspection_reports.follow_up')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
INNER JOIN roles role ON role.code = mapping.role_code
INNER JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT DO NOTHING
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_inspection_reports_gedo_if_identifier
ON inspection_reports(gedo_if_identifier)
WHERE gedo_if_identifier IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS uq_inspection_reports_gedo_if_identifier`);
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
)
`);
await queryRunner.query(`
DELETE FROM permissions
WHERE code IN (
'inspection_reports.edit',
'inspection_reports.officialize',
'inspection_reports.follow_up'
)
`);
}
}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { InspectionReportFollowUpType } from '../../database/entities';
export class CreateInspectionReportFollowUpDto {
@IsEnum(InspectionReportFollowUpType)
eventType!: InspectionReportFollowUpType;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(255)
referenceNumber?: string | null;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
occurredOn!: string;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' && value.trim() ? value.trim() : null))
@IsString()
@MaxLength(20000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
export class OfficializeInspectionReportGedoDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(3)
@MaxLength(255)
ifIdentifier!: string;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
officializedOn!: string;
}
@@ -0,0 +1,58 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
function optionalText(value: unknown): unknown {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
export class UpdateInspectionReportContentDto {
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(10000)
referenceText?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
generalObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(20000)
specificObjective?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
background?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
legalFramework?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
executiveSummary?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(80000)
description?: string | null;
@IsOptional()
@Transform(({ value }) => optionalText(value))
@IsString()
@MaxLength(40000)
conclusion?: string | null;
}
@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
export const MAX_REPORT_DOSSIER_PDF_BYTES = 25 * 1024 * 1024;
export interface UploadedReportDossierFile {
buffer: Buffer;
originalname: string;
mimetype?: string;
size: number;
}
export interface InspectedReportDossierPdf {
originalName: string;
mimeType: 'application/pdf';
extension: '.pdf';
}
export function inspectReportDossierPdf(
file: UploadedReportDossierFile | undefined,
): InspectedReportDossierPdf {
if (!file?.buffer?.length || file.size <= 0) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_REQUIRED',
message: 'Debés adjuntar un archivo PDF no vacío',
});
}
if (file.size > MAX_REPORT_DOSSIER_PDF_BYTES || file.buffer.length > MAX_REPORT_DOSSIER_PDF_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_TOO_LARGE',
message: 'El PDF supera el máximo permitido de 25 MB',
});
}
if (file.buffer.length < 5 || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID',
message: 'El archivo adjunto no tiene una estructura PDF válida',
});
}
const originalName = file.originalname
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
if (!originalName) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_PDF_INVALID_NAME',
message: 'El nombre original del PDF no es válido',
});
}
return { originalName, mimeType: 'application/pdf', extension: '.pdf' };
}
@@ -0,0 +1,128 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
Res,
UploadedFile,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } 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 { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
MAX_REPORT_DOSSIER_PDF_BYTES,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
@Controller('inspection-reports/:reportId/dossier')
export class InspectionReportDossierController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.get(reportId);
}
@Patch('content')
@RequirePermissions('inspection_reports.edit')
updateContent(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: UpdateInspectionReportContentDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.updateContent(reportId, dto, principal, request);
}
@Post('gedo')
@RequirePermissions('inspection_reports.officialize')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 1 },
}))
officializeGedo(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: OfficializeInspectionReportGedoDto,
@UploadedFile() file: UploadedReportDossierFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.officializeGedo(reportId, dto, file, principal, request);
}
@Get('gedo/pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdf(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.gedoPdfContent(reportId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
@Get('follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string) {
return this.dossier.listFollowUps(reportId);
}
@Post('follow-ups')
@RequirePermissions('inspection_reports.follow_up')
@UseInterceptors(FilesInterceptor('files', 10, {
limits: { fileSize: MAX_REPORT_DOSSIER_PDF_BYTES, files: 10 },
}))
createFollowUp(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportFollowUpDto,
@UploadedFiles() files: UploadedReportDossierFile[] | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.dossier.createFollowUp(reportId, dto, files ?? [], principal, request);
}
}
@Controller('inspection-report-follow-up-files')
export class InspectionReportFollowUpFileController {
constructor(private readonly dossier: InspectionReportDossierService) {}
@Get(':fileId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('fileId', new ParseUUIDPipe({ version: '4' })) fileId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.dossier.followUpFileContent(fileId);
response.setHeader('Content-Type', 'application/pdf');
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -0,0 +1,516 @@
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,
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 {
InspectionReport,
InspectionReportFollowUp,
InspectionReportFollowUpFile,
InspectionReportFollowUpType,
} from '../database/entities';
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportGedoDto } from './dto/officialize-inspection-report-gedo.dto';
import type { UpdateInspectionReportContentDto } from './dto/update-inspection-report-content.dto';
import {
inspectReportDossierPdf,
type UploadedReportDossierFile,
} from './inspection-report-dossier-file';
interface ReportContext {
id: string;
code: string;
actId: string;
visitId: string;
actCode: string;
visitCode: string;
generatedBy: string;
gedoIfIdentifier: string | null;
gedoOfficializedOn: string | null;
gedoPdfStoredName: string | null;
gedoPdfSizeBytes: number | null;
gedoPdfSha256: string | null;
}
@Injectable()
export class InspectionReportDossierService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly deadlines: InspectionDeadlinesService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_DOSSIER_ROOT')
?? '/app/storage/asset-media/inspection-report-dossier';
if (!isAbsolute(configured)) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT must be an absolute path');
}
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_DOSSIER_ROOT cannot be the filesystem root');
}
}
async get(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const [report] = await this.dataSource.query(`
SELECT
report.id,
report.code,
report.reference_text AS "referenceText",
report.general_objective AS "generalObjective",
report.specific_objective AS "specificObjective",
report.background,
report.legal_framework AS "legalFramework",
report.executive_summary AS "executiveSummary",
report.description,
report.conclusion,
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
report.gedo_pdf_mime_type AS "gedoPdfMimeType",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.gedo_pdf_uploaded_at AS "gedoPdfUploadedAt",
act.id AS "actId",
act.code AS "actCode",
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_on::text AS "deadlineBaseOn",
act.deadline_due_on::text AS "deadlineDueOn",
act.status AS "actStatus",
visit.id AS "visitId",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
`, [reportId]) as Array<Record<string, unknown>>;
const followUps = await this.listFollowUps(reportId);
return { ...report, followUps: followUps.data };
}
async updateContent(
reportId: string,
dto: UpdateInspectionReportContentDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
const repository = manager.getRepository(InspectionReport);
const report = await repository.findOne({ where: { id: reportId } });
if (!report) throw this.notFound();
const before = this.contentSnapshot(report);
const fields: Array<keyof UpdateInspectionReportContentDto> = [
'referenceText',
'generalObjective',
'specificObjective',
'background',
'legalFramework',
'executiveSummary',
'description',
'conclusion',
];
let changed = false;
for (const field of fields) {
if (dto[field] !== undefined) {
(report as unknown as Record<string, unknown>)[field] = dto[field] ?? null;
changed = true;
}
}
if (!changed) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_CONTENT_EMPTY_UPDATE',
message: 'No se recibió ningún campo del Informe para actualizar',
});
}
await repository.save(report);
const after = this.contentSnapshot(report);
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_CONTENT_UPDATED',
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: after,
metadata: { actId: context.actId, visitId: context.visitId },
}, manager);
return after;
});
}
async officializeGedo(
reportId: string,
dto: OfficializeInspectionReportGedoDto,
file: UploadedReportDossierFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const inspected = inspectReportDossierPdf(file);
this.assertDate(dto.officializedOn, 'La fecha de carga en GEDO no es válida');
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `gedo-${reportId}-${randomUUID()}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
if (context.gedoIfIdentifier || context.gedoOfficializedOn || context.gedoPdfStoredName) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'Este Informe ya tiene un IF oficial de GEDO y no puede sobrescribirse',
});
}
await manager.query(`
UPDATE inspection_reports
SET gedo_if_identifier = $2,
gedo_officialized_on = $3::date,
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,
gedo_pdf_uploaded_at = CURRENT_TIMESTAMP,
gedo_pdf_uploaded_by = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [
reportId,
dto.ifIdentifier,
dto.officializedOn,
inspected.originalName,
storedName,
file!.buffer.length,
sha256,
principal.userId,
]);
const deadlineDueOn = await this.deadlines.applyGedoOfficialization(
manager,
context.actId,
dto.officializedOn,
);
const after = {
gedoIfIdentifier: dto.ifIdentifier,
gedoOfficializedOn: dto.officializedOn,
gedoPdfOriginalName: inspected.originalName,
gedoPdfSizeBytes: file!.buffer.length,
gedoPdfSha256: sha256,
deadlineDueOn,
};
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_GEDO_OFFICIALIZED',
entityType: 'inspection_report',
entityId: reportId,
afterData: after,
metadata: {
actId: context.actId,
visitId: context.visitId,
officialLegalDocument: true,
},
}, manager);
return after;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async gedoPdfContent(reportId: string) {
const context = await this.requireReport(this.dataSource.manager, reportId);
if (!context.gedoPdfStoredName || !context.gedoPdfSizeBytes || !context.gedoPdfSha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_FOUND',
message: 'El Informe todavía no tiene un PDF oficial de GEDO',
});
}
const filePath = this.safePath(context.gedoPdfStoredName);
await this.verifyStoredFile(filePath, context.gedoPdfSizeBytes, context.gedoPdfSha256);
return { filePath, originalName: `${context.gedoIfIdentifier ?? context.code}.pdf` };
}
async listFollowUps(reportId: string) {
await this.requireReport(this.dataSource.manager, reportId);
const data = await this.dataSource.query(`
SELECT
follow_up.id,
follow_up.event_type AS "eventType",
follow_up.reference_number AS "referenceNumber",
follow_up.occurred_on::text AS "occurredOn",
follow_up.description,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt",
COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', file.id,
'originalName', file.original_name,
'mimeType', file.mime_type,
'sizeBytes', file.size_bytes,
'sha256', file.sha256,
'createdAt', file.created_at
) ORDER BY file.created_at, file.id)
FROM inspection_report_follow_up_files file
WHERE file.follow_up_id = follow_up.id
), '[]'::jsonb) AS files
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id = $1
ORDER BY follow_up.occurred_on ASC, follow_up.created_at ASC, follow_up.id ASC
`, [reportId]);
return { data };
}
async createFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
files: UploadedReportDossierFile[],
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertDate(dto.occurredOn, 'La fecha del seguimiento no es válida');
if (files.length > 10) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FOLLOW_UP_TOO_MANY_FILES',
message: 'Se permiten hasta 10 archivos PDF por registro de seguimiento',
});
}
if (dto.eventType === InspectionReportFollowUpType.COMPANY_NOTE && files.length < 1) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_COMPANY_NOTE_FILE_REQUIRED',
message: 'La respuesta formal de la empresa debe conservar al menos el PDF de la Nota recibida',
});
}
const inspectedFiles = files.map((file) => ({ file, inspected: inspectReportDossierPdf(file) }));
const followUpId = randomUUID();
const stored: Array<{
id: string;
storedName: string;
filePath: string;
originalName: string;
sizeBytes: number;
sha256: string;
}> = [];
await mkdir(this.root, { recursive: true, mode: 0o700 });
try {
for (const item of inspectedFiles) {
const id = randomUUID();
const storedName = `follow-up-${followUpId}-${id}.pdf`;
const filePath = this.safePath(storedName);
const sha256 = createHash('sha256').update(item.file.buffer).digest('hex');
await writeFile(filePath, item.file.buffer, { mode: 0o600 });
stored.push({
id,
storedName,
filePath,
originalName: item.inspected.originalName,
sizeBytes: item.file.buffer.length,
sha256,
});
}
await this.dataSource.transaction(async (manager) => {
const context = await this.requireReport(manager, reportId, true);
await this.assertCanWorkReport(manager, context, principal);
await manager.getRepository(InspectionReportFollowUp).insert({
id: followUpId,
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
createdBy: principal.userId,
});
for (const file of stored) {
await manager.getRepository(InspectionReportFollowUpFile).insert({
id: file.id,
followUpId,
originalName: file.originalName,
storedName: file.storedName,
mimeType: 'application/pdf',
sizeBytes: file.sizeBytes,
sha256: file.sha256,
createdBy: principal.userId,
});
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_REPORT_FOLLOW_UP_CREATED',
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
eventType: dto.eventType,
referenceNumber: dto.referenceNumber ?? null,
occurredOn: dto.occurredOn,
description: dto.description ?? null,
files: stored.map((file) => ({
id: file.id,
originalName: file.originalName,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})),
},
metadata: {
actId: context.actId,
visitId: context.visitId,
appendOnly: true,
},
}, manager);
});
return this.listFollowUps(reportId);
} catch (error) {
await Promise.all(stored.map((file) => unlink(file.filePath).catch(() => undefined)));
throw error;
}
}
async followUpFileContent(fileId: string) {
const [file] = (await this.dataSource.query(`
SELECT
file.original_name AS "originalName",
file.stored_name AS "storedName",
file.size_bytes AS "sizeBytes",
file.sha256
FROM inspection_report_follow_up_files file
WHERE file.id = $1
`, [fileId])) as Array<{
originalName: string;
storedName: string;
sizeBytes: number;
sha256: string;
}>;
if (!file) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_FOLLOW_UP_FILE_NOT_FOUND',
message: 'Documento de seguimiento no encontrado',
});
}
const filePath = this.safePath(file.storedName);
await this.verifyStoredFile(filePath, Number(file.sizeBytes), file.sha256);
return { filePath, originalName: file.originalName };
}
private async requireReport(
manager: EntityManager,
reportId: string,
lock = false,
): Promise<ReportContext> {
const suffix = lock ? 'FOR UPDATE OF report' : '';
const [row] = (await manager.query(`
SELECT
report.id,
report.code,
report.act_id AS "actId",
report.visit_id AS "visitId",
report.generated_by AS "generatedBy",
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_on::text AS "gedoOfficializedOn",
report.gedo_pdf_stored_name AS "gedoPdfStoredName",
report.gedo_pdf_size_bytes AS "gedoPdfSizeBytes",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
act.code AS "actCode",
visit.code AS "visitCode"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
WHERE report.id = $1
${suffix}
`, [reportId])) as ReportContext[];
if (!row) throw this.notFound();
return row;
}
private async assertCanWorkReport(
manager: EntityManager,
report: ReportContext,
principal: AuthPrincipal,
): Promise<void> {
if (principal.roles.includes('admin') || principal.roles.includes('supervisor')) return;
if (report.generatedBy === principal.userId) return;
const [member] = (await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [report.visitId, principal.userId])) as Array<{ found: number }>;
if (member) return;
throw new ForbiddenException({
code: 'INSPECTION_REPORT_NOT_ASSIGNED',
message: 'Sólo el inspector asignado a la inspección o su jefatura puede trabajar este Informe',
});
}
private contentSnapshot(report: InspectionReport): Record<string, unknown> {
return {
referenceText: report.referenceText,
generalObjective: report.generalObjective,
specificObjective: report.specificObjective,
background: report.background,
legalFramework: report.legalFramework,
executiveSummary: report.executiveSummary,
description: report.description,
conclusion: report.conclusion,
};
}
private assertDate(value: string, message: string): void {
const parsed = new Date(`${value}T00:00:00Z`);
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value) {
throw new BadRequestException({ code: 'INSPECTION_REPORT_DATE_INVALID', message });
}
}
private safePath(storedName: string): string {
const filePath = resolve(this.root, storedName);
if (!filePath.startsWith(`${this.root}/`)) {
throw new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INVALID_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
return filePath;
}
private async verifyStoredFile(filePath: string, expectedSize: number, expectedSha256: string): Promise<void> {
const metadata = await stat(filePath).catch(() => null);
if (!metadata?.isFile() || metadata.size !== expectedSize) throw this.storageError();
const buffer = await readFile(filePath);
const actualSha256 = createHash('sha256').update(buffer).digest('hex');
if (actualSha256 !== expectedSha256) throw this.storageError();
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_STORAGE_INTEGRITY_ERROR',
message: 'El archivo almacenado no supera la verificación de integridad',
});
}
private notFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
}
@@ -1,31 +1,35 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
import { InspectionReportReviewService } from './inspection-report-review.service';
import {
InspectionReportDossierController,
InspectionReportFollowUpFileController,
} from './inspection-report-dossier.controller';
import { InspectionReportDossierService } from './inspection-report-dossier.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({
imports: [AuditModule],
imports: [AuditModule, InspectionDeadlinesModule],
controllers: [
InspectionReportsController,
InspectionActReportController,
InspectionReportDossierController,
InspectionReportFollowUpFileController,
DocumentDeliveryController,
InspectionReportReviewController,
InspectionReportRevisionContentController,
],
providers: [
InspectionReportsService,
InspectionReportWordService,
InspectionReportDossierService,
InspectionActPdfService,
InspectionDocumentDeliveryService,
SmtpDeliveryService,
InspectionReportReviewService,
],
exports: [InspectionReportsService],
})