import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { createHash, randomUUID } from 'crypto'; import { mkdir, stat, unlink, writeFile } from 'fs/promises'; import { join, resolve } from 'path'; import { DataSource } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto'; import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto'; export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024; export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer } const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses'); @Injectable() export class ActAdministrationService { constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {} private baseCte() { return ` WITH act_rows AS ( SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt", ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode", v.operational_area_id AS "areaId", v.operator_company_id AS "companyId", area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName", dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason", dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn", rsp.committed_correction_on AS "committedCorrectionOn", COALESCE(fc.finding_count, 0)::int AS "findingCount", COALESCE(fc.open_count, 0)::int AS "openFindingCount", COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount" FROM inspection_acts ia JOIN inspection_visits v ON v.id = ia.visit_id LEFT JOIN assets area ON area.id = v.operational_area_id LEFT JOIN assets company ON company.id = v.operator_company_id LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id LEFT JOIN LATERAL ( SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1 ) dl ON true LEFT JOIN LATERAL ( SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1 ) rsp ON true LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count, COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count, COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count FROM inspection_findings f WHERE f.act_id = ia.id ) fc ON true WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED') ), classified AS ( SELECT *, CASE WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED' WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE' WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING' WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED' WHEN "responseDueOn" IS NULL THEN 'NEW' WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE' WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON' ELSE 'WAITING_RESPONSE' END AS "adminState" FROM act_rows )`; } async queue(query: ListActAdministrationQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 25; const filters: string[] = []; const args: unknown[] = []; const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); }; if (query.areaId) add('"areaId" = ?', query.areaId); if (query.companyId) add('"companyId" = ?', query.companyId); if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state); const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; const rows = await this.dataSource.query( `${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`, [...args, pageSize, (page - 1) * pageSize], ) as Array>; const total = Number(rows[0]?.totalRows ?? 0); const counterFilters: string[] = []; const counterArgs: unknown[] = []; if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); } if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); } const countersRaw = await this.dataSource.query( `${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`, counterArgs, ) as Array<{ state: string; count: number }>; const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)])); return { data: rows.map(({ totalRows: _ignored, ...row }) => row), counters, meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, }; } async detail(actId: string) { const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]); const act = rows[0]; if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' }); const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]); const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]); const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]); return { act, deadlines, responses, findings }; } private async ensureClosedAct(actId: string) { const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]); const act = rows[0]; if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' }); if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' }); return act; } async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { const act = await this.ensureClosedAct(actId); const rows = await this.dataSource.query( `INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, [randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId], ); await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } }); return rows[0]; } async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { const act = await this.ensureClosedAct(actId); let storedName: string | null = null; let sha256: string | null = null; if (file) { if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' }); if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' }); await mkdir(STORAGE, { recursive: true }); sha256 = createHash('sha256').update(file.buffer).digest('hex'); storedName = `${randomUUID()}.pdf`; await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' }); } try { const rows = await this.dataSource.query( `INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`, [randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId], ); await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } }); return rows[0]; } catch (error) { if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); throw error; } } async responseContent(responseId: string) { const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]); const row = rows[0]; if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' }); const filePath = join(STORAGE, row.storedName); const info = await stat(filePath).catch(() => null); if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' }); return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath }; } async calendar(from?: string, to?: string) { const dateRe = /^\d{4}-\d{2}-\d{2}$/; if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' }); const start = from ?? new Date().toISOString().slice(0, 10); const endDate = new Date(`${to ?? start}T00:00:00Z`); if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60); const end = endDate.toISOString().slice(0, 10); if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' }); const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]); const events: Array> = []; for (const row of rows) { if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState }); if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState }); } return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) }; } }