682 lines
23 KiB
TypeScript
682 lines
23 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|