chore: import DH V2 D5.6.4 production baseline

This commit is contained in:
DH V2
2026-09-05 10:12:35 -03:00
commit 82213e72f5
757 changed files with 84218 additions and 0 deletions
@@ -0,0 +1,681 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, 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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import {
AuditAction,
InspectionActStatus,
InspectionCommunicationDirection,
InspectionCommunicationType,
InspectionEvidenceKind,
InspectionEvidencePurpose,
InspectionEvidenceSource,
InspectionFindingStatus,
InspectionVisitStatus,
} from '../database/entities';
import type { CreateInspectionCommunicationDto } from './dto/create-inspection-communication.dto';
import type { CreateInspectionEvidenceDto } from './dto/create-inspection-evidence.dto';
import {
inspectInspectionEvidenceFile,
type UploadedInspectionEvidenceFile,
} from './inspection-evidence-file';
interface FindingContext {
id: string;
status: InspectionFindingStatus;
actId: string;
actStatus: InspectionActStatus;
visitId: string;
visitStatus: InspectionVisitStatus;
}
interface CommunicationLink {
id: string;
type: InspectionCommunicationType;
direction: InspectionCommunicationDirection;
subject: string;
}
export interface InspectionCommunicationView {
id: string;
findingId: string;
direction: string;
channel: string;
type: string;
occurredAt: Date;
subject: string;
details: string | null;
contactName: string | null;
contactEmail: string | null;
createdBy: string | null;
createdByUsername: string | null;
attachmentCount: number;
createdAt: Date;
}
export interface InspectionEvidenceView {
id: string;
findingId: string;
communicationId: string | null;
verificationVisitId: string | null;
communication: CommunicationLink | null;
kind: InspectionEvidenceKind;
purpose: InspectionEvidencePurpose;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
title: string | null;
description: string | null;
capturedAt: Date | null;
latitude: number | null;
longitude: number | null;
accuracyM: number | null;
deviceLabel: string | null;
source: InspectionEvidenceSource;
uploadedBy: string | null;
uploadedByUsername: string | null;
createdAt: Date;
}
interface StoredInspectionEvidence extends InspectionEvidenceView {
storedName: string;
}
function findingNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo de inspección no encontrado',
});
}
function evidenceNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_EVIDENCE_NOT_FOUND',
message: 'Evidencia de inspección no encontrada',
});
}
@Injectable()
export class InspectionEvidenceService {
private readonly storageRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_EVIDENCE_ROOT')
?? '/app/storage/asset-media/inspection-findings';
if (!isAbsolute(configured)) {
throw new Error('INSPECTION_EVIDENCE_ROOT must be an absolute path');
}
this.storageRoot = resolve(configured);
if (this.storageRoot === parse(this.storageRoot).root) {
throw new Error('INSPECTION_EVIDENCE_ROOT cannot be the filesystem root');
}
}
async listEvidence(findingId: string): Promise<{ data: InspectionEvidenceView[] }> {
await this.requireFinding(this.dataSource.manager, findingId);
const rows = await this.dataSource.query(
`${this.evidenceSelect()}
WHERE evidence.finding_id = $1
ORDER BY evidence.created_at DESC, evidence.id`,
[findingId],
) as StoredInspectionEvidence[];
return {
data: rows.map(({ storedName: _storedName, ...evidence }) => evidence),
};
}
async uploadEvidence(
findingId: string,
dto: CreateInspectionEvidenceDto,
file: UploadedInspectionEvidenceFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionEvidenceView> {
if ([InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)) {
assertMobileInspector(principal);
}
this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM);
const inspected = inspectInspectionEvidenceFile(file, dto.kind);
this.validatePurpose(dto, inspected.mimeType);
const id = randomUUID();
const storedName = `${id}${inspected.extension}`;
const filePath = resolve(this.storageRoot, storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const source = principal.transport === 'bearer'
? InspectionEvidenceSource.ANDROID
: InspectionEvidenceSource.WEB;
await mkdir(this.storageRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const context = await this.lockFindingContext(manager, findingId);
this.assertFindingOpen(context);
if (dto.purpose === InspectionEvidencePurpose.OBSERVATION) {
this.assertObservationEditable(context);
await this.assertActorAssigned(manager, context.visitId, principal);
if (dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_OBSERVATION_VERIFICATION_VISIT_NOT_ALLOWED',
message: 'La evidencia inicial no debe indicar una visita de verificación',
});
}
}
if (dto.purpose === InspectionEvidencePurpose.VERIFICATION) {
if (!dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_VISIT_REQUIRED',
message: 'La foto de verificación debe indicar la visita en curso',
});
}
await this.assertVerificationEditable(
manager,
findingId,
dto.verificationVisitId,
principal,
);
} else if (dto.verificationVisitId) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_VISIT_PURPOSE_INVALID',
message: 'La visita de verificación sólo corresponde a evidencia de verificación',
});
}
const communication = dto.communicationId
? await this.requireCommunication(manager, findingId, dto.communicationId)
: null;
this.validateCommunicationPurpose(dto.purpose, communication);
await manager.query(`
INSERT INTO inspection_finding_evidence (
id, finding_id, communication_id, verification_visit_id, kind, purpose,
original_name, stored_name, mime_type, size_bytes, sha256,
title, description, captured_at, latitude, longitude, accuracy_m,
device_label, source, uploaded_by
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15, $16, $17,
$18, $19, $20
)
`, [
id,
findingId,
dto.communicationId ?? null,
dto.verificationVisitId ?? null,
dto.kind,
dto.purpose,
inspected.originalName,
storedName,
inspected.mimeType,
file!.buffer.length,
sha256,
dto.title?.trim() || null,
dto.description?.trim() || null,
dto.capturedAt ? new Date(dto.capturedAt) : null,
dto.latitude ?? null,
dto.longitude ?? null,
dto.accuracyM ?? null,
dto.deviceLabel?.trim() || null,
source,
principal.userId,
]);
const created = await this.loadEvidence(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_EVIDENCE_UPLOADED,
entityType: 'inspection_finding_evidence',
entityId: id,
afterData: this.evidenceAuditView(created),
metadata: {
findingId,
actId: context.actId,
visitId: dto.verificationVisitId ?? context.visitId,
sourceVisitId: context.visitId,
verificationVisitId: dto.verificationVisitId ?? null,
communicationId: dto.communicationId ?? null,
},
}, manager);
const { storedName: _storedName, ...view } = created;
return view;
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async listCommunications(
findingId: string,
): Promise<{ data: InspectionCommunicationView[] }> {
await this.requireFinding(this.dataSource.manager, findingId);
return {
data: await this.dataSource.query(
`${this.communicationSelect('WHERE communication.finding_id = $1')}
ORDER BY communication.occurred_at DESC, communication.created_at DESC`,
[findingId],
) as InspectionCommunicationView[],
};
}
async createCommunication(
findingId: string,
dto: CreateInspectionCommunicationDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionCommunicationView> {
if (
dto.type === InspectionCommunicationType.COMPANY_RESPONSE
&& dto.direction !== InspectionCommunicationDirection.INBOUND
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_MUST_BE_INBOUND',
message: 'Una respuesta de la empresa debe registrarse como comunicación recibida',
});
}
return this.dataSource.transaction(async (manager) => {
const context = await this.lockFindingContext(manager, findingId);
this.assertFindingOpen(context);
const id = randomUUID();
await manager.query(`
INSERT INTO inspection_finding_communications (
id, finding_id, direction, channel, type, occurred_at,
subject, details, contact_name, contact_email, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, [
id,
findingId,
dto.direction,
dto.channel,
dto.type,
new Date(dto.occurredAt),
dto.subject,
dto.details ?? null,
dto.contactName ?? null,
dto.contactEmail?.toLowerCase() ?? null,
principal.userId,
]);
const created = await this.loadCommunication(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_COMMUNICATION_CREATED,
entityType: 'inspection_finding_communication',
entityId: id,
afterData: created as unknown as Record<string, unknown>,
metadata: {
findingId,
actId: context.actId,
visitId: context.visitId,
immutable: true,
},
}, manager);
return created;
});
}
async content(evidenceId: string): Promise<{
filePath: string;
evidence: StoredInspectionEvidence;
}> {
const evidence = await this.loadEvidence(this.dataSource.manager, evidenceId);
const filePath = resolve(this.storageRoot, evidence.storedName);
if (!filePath.startsWith(`${this.storageRoot}/`)) {
throw new InternalServerErrorException({
code: 'INVALID_INSPECTION_EVIDENCE_STORAGE_PATH',
message: 'Ruta de almacenamiento inválida',
});
}
try {
const fileStat = await stat(filePath);
if (!fileStat.isFile() || fileStat.size !== evidence.sizeBytes) {
throw new Error('size mismatch');
}
} catch {
throw new InternalServerErrorException({
code: 'INSPECTION_EVIDENCE_FILE_MISSING',
message: 'El archivo físico no está disponible',
});
}
return { filePath, evidence };
}
private validateCoordinates(
latitude: number | null | undefined,
longitude: number | null | undefined,
accuracyM: number | null | undefined,
): void {
const hasLatitude = latitude !== null && latitude !== undefined;
const hasLongitude = longitude !== null && longitude !== undefined;
if (hasLatitude !== hasLongitude) {
throw new BadRequestException({
code: 'INVALID_INSPECTION_EVIDENCE_COORDINATES',
message: 'Latitud y longitud deben informarse juntas',
});
}
if (accuracyM !== null && accuracyM !== undefined && !hasLatitude) {
throw new BadRequestException({
code: 'INSPECTION_EVIDENCE_ACCURACY_WITHOUT_COORDINATES',
message: 'La precisión requiere coordenadas',
});
}
}
private validatePurpose(dto: CreateInspectionEvidenceDto, mimeType: string): void {
if (
dto.kind === InspectionEvidenceKind.PHOTO
&& ![InspectionEvidencePurpose.OBSERVATION, InspectionEvidencePurpose.VERIFICATION].includes(dto.purpose)
) {
throw new BadRequestException({
code: 'INSPECTION_EVIDENCE_PHOTO_PURPOSE_INVALID',
message: 'Las fotografías se registran como evidencia inicial o de verificación',
});
}
if (dto.purpose === InspectionEvidencePurpose.VERIFICATION && dto.kind !== InspectionEvidenceKind.PHOTO) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_PHOTO_REQUIRED',
message: 'La evidencia de verificación debe ser una fotografía',
});
}
if (
dto.purpose === InspectionEvidencePurpose.COMPANY_RESPONSE
&& (dto.kind !== InspectionEvidenceKind.DOCUMENT || mimeType !== 'application/pdf')
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_PDF_REQUIRED',
message: 'La respuesta de la empresa debe adjuntarse como PDF',
});
}
if (
dto.purpose === InspectionEvidencePurpose.OTHER_DOCUMENT
&& dto.kind !== InspectionEvidenceKind.DOCUMENT
) {
throw new BadRequestException({
code: 'INSPECTION_OTHER_DOCUMENT_PDF_REQUIRED',
message: 'Otros documentos deben adjuntarse como PDF',
});
}
}
private validateCommunicationPurpose(
purpose: InspectionEvidencePurpose,
communication: CommunicationLink | null,
): void {
if (
purpose === InspectionEvidencePurpose.COMPANY_RESPONSE
&& (!communication
|| communication.type !== InspectionCommunicationType.COMPANY_RESPONSE
|| communication.direction !== InspectionCommunicationDirection.INBOUND)
) {
throw new BadRequestException({
code: 'COMPANY_RESPONSE_COMMUNICATION_REQUIRED',
message: 'El PDF debe vincularse con una respuesta de empresa registrada',
});
}
if (
purpose === InspectionEvidencePurpose.COMMUNICATION_ATTACHMENT
&& !communication
) {
throw new BadRequestException({
code: 'COMMUNICATION_ATTACHMENT_LINK_REQUIRED',
message: 'El documento debe vincularse con una comunicación',
});
}
if (purpose === InspectionEvidencePurpose.VERIFICATION && communication) {
throw new BadRequestException({
code: 'VERIFICATION_EVIDENCE_COMMUNICATION_NOT_ALLOWED',
message: 'La foto de verificación se vincula con la visita, no con una comunicación',
});
}
}
private assertFindingOpen(context: FindingContext): void {
if (context.status !== InspectionFindingStatus.OPEN) {
throw new ConflictException({
code: 'INSPECTION_FINDING_NOT_OPEN',
message: 'No se pueden agregar registros a un hallazgo cerrado',
});
}
}
private assertObservationEditable(context: FindingContext): void {
if (
context.actStatus !== InspectionActStatus.DRAFT
|| context.visitStatus !== InspectionVisitStatus.IN_PROGRESS
) {
throw new ConflictException({
code: 'INSPECTION_OBSERVATION_EVIDENCE_LOCKED',
message: 'La evidencia de campo se carga durante la visita y con el acta en borrador',
});
}
}
private async assertVerificationEditable(
manager: EntityManager,
findingId: string,
verificationVisitId: string,
principal: AuthPrincipal,
): Promise<void> {
const [row] = await manager.query(`
SELECT visit.id, visit.status
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = $1 AND verification_link.visit_id = $2
`, [findingId, verificationVisitId]) as Array<{ id: string; status: InspectionVisitStatus }>;
if (!row) {
throw new BadRequestException({
code: 'INSPECTION_VERIFICATION_LINK_INVALID',
message: 'El hallazgo no pertenece a la visita de verificación indicada',
});
}
if (row.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_VERIFICATION_EVIDENCE_LOCKED',
message: 'La evidencia de verificación sólo se carga durante la visita en curso',
});
}
await this.assertActorAssigned(manager, verificationVisitId, principal);
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const rows = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
`, [visitId, principal.userId]) as unknown[];
if (rows.length === 0) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La visita no está asignada al usuario actual',
});
}
}
private async requireFinding(manager: EntityManager, id: string): Promise<void> {
const rows = await manager.query(
'SELECT 1 FROM inspection_findings WHERE id = $1',
[id],
) as unknown[];
if (rows.length === 0) throw findingNotFound();
}
private async lockFindingContext(
manager: EntityManager,
id: string,
): Promise<FindingContext> {
const [context] = await manager.query(`
SELECT
finding.id,
finding.status,
act.id AS "actId",
act.status AS "actStatus",
visit.id AS "visitId",
visit.status AS "visitStatus"
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.id = $1
FOR UPDATE OF finding
`, [id]) as FindingContext[];
if (!context) throw findingNotFound();
return context;
}
private async requireCommunication(
manager: EntityManager,
findingId: string,
communicationId: string,
): Promise<CommunicationLink> {
const [communication] = await manager.query(`
SELECT id, type, direction, subject
FROM inspection_finding_communications
WHERE id = $1 AND finding_id = $2
`, [communicationId, findingId]) as CommunicationLink[];
if (!communication) {
throw new BadRequestException({
code: 'INSPECTION_COMMUNICATION_INVALID',
message: 'La comunicación seleccionada no pertenece al hallazgo',
});
}
return communication;
}
private evidenceSelect(): string {
return `
SELECT
evidence.id,
evidence.finding_id AS "findingId",
evidence.communication_id AS "communicationId",
evidence.verification_visit_id AS "verificationVisitId",
CASE WHEN communication.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', communication.id,
'type', communication.type,
'direction', communication.direction,
'subject', communication.subject
) END AS communication,
evidence.kind,
evidence.purpose,
evidence.original_name AS "originalName",
evidence.stored_name AS "storedName",
evidence.mime_type AS "mimeType",
evidence.size_bytes::integer AS "sizeBytes",
evidence.sha256,
evidence.title,
evidence.description,
evidence.captured_at AS "capturedAt",
evidence.latitude::double precision AS latitude,
evidence.longitude::double precision AS longitude,
evidence.accuracy_m::double precision AS "accuracyM",
evidence.device_label AS "deviceLabel",
evidence.source,
evidence.uploaded_by AS "uploadedBy",
uploader.username AS "uploadedByUsername",
evidence.created_at AS "createdAt"
FROM inspection_finding_evidence evidence
LEFT JOIN inspection_finding_communications communication
ON communication.id = evidence.communication_id
LEFT JOIN users uploader ON uploader.id = evidence.uploaded_by
`;
}
private async loadEvidence(
manager: EntityManager,
id: string,
): Promise<StoredInspectionEvidence> {
const [evidence] = await manager.query(
`${this.evidenceSelect()} WHERE evidence.id = $1`,
[id],
) as StoredInspectionEvidence[];
if (!evidence) throw evidenceNotFound();
return evidence;
}
private communicationSelect(where: string): string {
return `
SELECT
communication.id,
communication.finding_id AS "findingId",
communication.direction,
communication.channel,
communication.type,
communication.occurred_at AS "occurredAt",
communication.subject,
communication.details,
communication.contact_name AS "contactName",
communication.contact_email AS "contactEmail",
communication.created_by AS "createdBy",
creator.username AS "createdByUsername",
COUNT(evidence.id)::integer AS "attachmentCount",
communication.created_at AS "createdAt"
FROM inspection_finding_communications communication
LEFT JOIN users creator ON creator.id = communication.created_by
LEFT JOIN inspection_finding_evidence evidence
ON evidence.communication_id = communication.id
${where}
GROUP BY communication.id, creator.username
`;
}
private async loadCommunication(
manager: EntityManager,
id: string,
): Promise<InspectionCommunicationView> {
const [communication] = await manager.query(
this.communicationSelect('WHERE communication.id = $1'),
[id],
) as InspectionCommunicationView[];
if (!communication) {
throw new NotFoundException({
code: 'INSPECTION_COMMUNICATION_NOT_FOUND',
message: 'Comunicación no encontrada',
});
}
return communication;
}
private evidenceAuditView(
evidence: StoredInspectionEvidence,
): Record<string, unknown> {
return {
findingId: evidence.findingId,
communicationId: evidence.communicationId,
verificationVisitId: evidence.verificationVisitId,
kind: evidence.kind,
purpose: evidence.purpose,
originalName: evidence.originalName,
mimeType: evidence.mimeType,
sizeBytes: evidence.sizeBytes,
sha256: evidence.sha256,
capturedAt: evidence.capturedAt,
latitude: evidence.latitude,
longitude: evidence.longitude,
accuracyM: evidence.accuracyM,
deviceLabel: evidence.deviceLabel,
source: evidence.source,
uploadedBy: evidence.uploadedBy,
immutable: true,
};
}
}