chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user