F4.3 · dossier INF, GEDO IF y seguimiento
This commit is contained in:
@@ -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 { Module } from '@nestjs/common';
|
||||||
import { AuditModule } from '../audit/audit.module';
|
import { AuditModule } from '../audit/audit.module';
|
||||||
|
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||||
import { DocumentDeliveryController } from './document-delivery.controller';
|
import { DocumentDeliveryController } from './document-delivery.controller';
|
||||||
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
import { InspectionActPdfService } from './inspection-act-pdf.service';
|
||||||
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
|
||||||
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
|
import {
|
||||||
import { InspectionReportReviewService } from './inspection-report-review.service';
|
InspectionReportDossierController,
|
||||||
|
InspectionReportFollowUpFileController,
|
||||||
|
} from './inspection-report-dossier.controller';
|
||||||
|
import { InspectionReportDossierService } from './inspection-report-dossier.service';
|
||||||
import { InspectionReportWordService } from './inspection-report-word.service';
|
import { InspectionReportWordService } from './inspection-report-word.service';
|
||||||
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
|
||||||
import { InspectionReportsService } from './inspection-reports.service';
|
import { InspectionReportsService } from './inspection-reports.service';
|
||||||
import { SmtpDeliveryService } from './smtp-delivery.service';
|
import { SmtpDeliveryService } from './smtp-delivery.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuditModule],
|
imports: [AuditModule, InspectionDeadlinesModule],
|
||||||
controllers: [
|
controllers: [
|
||||||
InspectionReportsController,
|
InspectionReportsController,
|
||||||
InspectionActReportController,
|
InspectionActReportController,
|
||||||
|
InspectionReportDossierController,
|
||||||
|
InspectionReportFollowUpFileController,
|
||||||
DocumentDeliveryController,
|
DocumentDeliveryController,
|
||||||
InspectionReportReviewController,
|
|
||||||
InspectionReportRevisionContentController,
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
InspectionReportsService,
|
InspectionReportsService,
|
||||||
InspectionReportWordService,
|
InspectionReportWordService,
|
||||||
|
InspectionReportDossierService,
|
||||||
InspectionActPdfService,
|
InspectionActPdfService,
|
||||||
InspectionDocumentDeliveryService,
|
InspectionDocumentDeliveryService,
|
||||||
SmtpDeliveryService,
|
SmtpDeliveryService,
|
||||||
InspectionReportReviewService,
|
|
||||||
],
|
],
|
||||||
exports: [InspectionReportsService],
|
exports: [InspectionReportsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||||
|
import {
|
||||||
|
InspectionReportDossierController,
|
||||||
|
InspectionReportFollowUpFileController,
|
||||||
|
} from '../../src/inspection-reports/inspection-report-dossier.controller';
|
||||||
|
|
||||||
|
function permissions(controller: object, method: string): string[] {
|
||||||
|
const handler = controller[method as keyof typeof controller];
|
||||||
|
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('F4 report dossier separates read, edit, GEDO officialization and append-only follow-up', () => {
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'get'), ['inspection_reports.read']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'updateContent'), ['inspection_reports.edit']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'officializeGedo'), ['inspection_reports.officialize']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'gedoPdf'), ['inspection_reports.read']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'followUps'), ['inspection_reports.read']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportDossierController.prototype, 'createFollowUp'), ['inspection_reports.follow_up']);
|
||||||
|
assert.deepEqual(permissions(InspectionReportFollowUpFileController.prototype, 'content'), ['inspection_reports.read']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||||
|
|
||||||
|
test('F4 keeps company responses at INF level and never asks for a Finding selector', () => {
|
||||||
|
const dto = read('src/inspection-reports/dto/create-inspection-report-follow-up.dto.ts');
|
||||||
|
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
|
||||||
|
assert.doesNotMatch(dto, /findingId|findingIds/);
|
||||||
|
assert.match(service, /InspectionReportFollowUpType\.COMPANY_NOTE/);
|
||||||
|
assert.doesNotMatch(service, /inspection_findings.*follow_up_id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F4 treats GEDO IF as the legal officialization of the existing INF and starts non-urgent deadline there', () => {
|
||||||
|
const service = read('src/inspection-reports/inspection-report-dossier.service.ts');
|
||||||
|
assert.match(service, /gedo_if_identifier/);
|
||||||
|
assert.match(service, /gedo_officialized_on/);
|
||||||
|
assert.match(service, /applyGedoOfficialization/);
|
||||||
|
assert.match(service, /INSPECTION_REPORT_ALREADY_OFFICIALIZED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('F4 removes the active Director review controller from the report module', () => {
|
||||||
|
const moduleSource = read('src/inspection-reports/inspection-reports.module.ts');
|
||||||
|
assert.doesNotMatch(moduleSource, /InspectionReportReviewController/);
|
||||||
|
assert.doesNotMatch(moduleSource, /InspectionReportReviewService/);
|
||||||
|
assert.match(moduleSource, /InspectionReportDossierController/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user