import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AssetHistoryService } from '../asset-master/asset-history.service'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { Asset, AssetDataOrigin, AssetInformationStatus, AssetVersionChangeType, AuditAction, SurveyCampaign, SurveyCampaignStatus, SurveyCampaignTarget, SurveyReportOutcome, SurveyReportStatus, SurveyReportVersionEvent, SurveyTargetReport, SurveyTargetStatus, } from '../database/entities'; import { SurveyReviewDecision, type ReviewSurveyReportDto, } from './dto/review-survey-report.dto'; import type { SaveSurveyReportDto } from './dto/save-survey-report.dto'; interface ExecutionPerson { id: string; username: string; firstName: string; lastName: string; } interface ExecutionContextView { target: { id: string; status: SurveyTargetStatus; dueAt: Date | null; instructions: string | null; assignedUser: ExecutionPerson | null; }; campaign: { id: string; code: string; name: string; status: SurveyCampaignStatus; }; asset: { id: string; code: string; name: string; typeName: string; informationStatus: AssetInformationStatus; currentVersion: number; }; } interface SurveyReportView { id: string; targetId: string; outcome: SurveyReportOutcome | null; status: SurveyReportStatus; observedAt: Date | null; latitude: number | null; longitude: number | null; accuracyM: number | null; notes: string | null; assetVersionAtSubmission: number | null; submittedAt: Date | null; submittedBy: ExecutionPerson | null; reviewedAt: Date | null; reviewedBy: ExecutionPerson | null; reviewNotes: string | null; selectedMediaIds: string[]; createdAt: Date; updatedAt: Date; } interface ReportMediaView { id: string; kind: 'PHOTO'; 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; source: string; createdAt: Date; selected: boolean; } interface ReportVersionView { id: string; versionNumber: number; event: SurveyReportVersionEvent; snapshot: Record; actorUserId: string | null; actorUsername: string | null; createdAt: Date; } export interface SurveyExecutionView extends ExecutionContextView { report: SurveyReportView | null; availableMedia: ReportMediaView[]; versions: ReportVersionView[]; } function targetNotFound(): NotFoundException { return new NotFoundException({ code: 'SURVEY_TARGET_NOT_FOUND', message: 'Objetivo de relevamiento no encontrado', }); } function reportNotFound(): NotFoundException { return new NotFoundException({ code: 'SURVEY_REPORT_NOT_FOUND', message: 'El objetivo todavía no tiene un informe de campo', }); } @Injectable() export class SurveyExecutionService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, ) {} async get(targetId: string): Promise { return this.dataSource.transaction((manager) => this.loadView(manager, targetId)); } async save( targetId: string, dto: SaveSurveyReportDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const { target, campaign } = await this.lockTargetContext(manager, targetId); this.assertCaptureAllowed(target, campaign, principal); const repository = manager.getRepository(SurveyTargetReport); let report = await this.lockReport(manager, targetId, false); const before = report ? this.reportAuditView(report) : null; if (!report) { report = repository.create({ targetId, outcome: null, status: SurveyReportStatus.DRAFT, observedAt: null, latitude: null, longitude: null, accuracyM: null, notes: null, assetVersionAtSubmission: null, submittedAt: null, submittedBy: null, reviewedAt: null, reviewedBy: null, reviewNotes: null, createdBy: principal.userId, updatedBy: principal.userId, }); } else if ( report.status === SurveyReportStatus.SUBMITTED || report.status === SurveyReportStatus.APPROVED ) { throw new ConflictException({ code: 'SURVEY_REPORT_LOCKED', message: 'El informe enviado o aprobado ya no puede editarse', }); } const nextLatitude = dto.latitude === undefined ? this.numberOrNull(report.latitude) : dto.latitude; const nextLongitude = dto.longitude === undefined ? this.numberOrNull(report.longitude) : dto.longitude; const nextAccuracy = dto.accuracyM === undefined ? this.numberOrNull(report.accuracyM) : dto.accuracyM; this.assertCoordinatePair(nextLatitude, nextLongitude, nextAccuracy); await this.validateMedia(manager, target.assetId, dto.mediaIds); if (dto.outcome !== undefined) report.outcome = dto.outcome; if (dto.observedAt !== undefined) { report.observedAt = dto.observedAt ? new Date(dto.observedAt) : null; } if (dto.latitude !== undefined) { report.latitude = dto.latitude == null ? null : String(dto.latitude); } if (dto.longitude !== undefined) { report.longitude = dto.longitude == null ? null : String(dto.longitude); } if (dto.accuracyM !== undefined) { report.accuracyM = dto.accuracyM == null ? null : String(dto.accuracyM); } if (dto.notes !== undefined) report.notes = dto.notes?.trim() || null; if (report.status === SurveyReportStatus.REJECTED) { report.status = SurveyReportStatus.DRAFT; report.assetVersionAtSubmission = null; report.submittedAt = null; report.submittedBy = null; report.reviewedAt = null; report.reviewedBy = null; report.reviewNotes = null; } report.updatedBy = principal.userId; await repository.save(report); await this.replaceMediaSelection(manager, report.id, dto.mediaIds, principal.userId); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.SURVEY_REPORT_SAVED, entityType: 'survey_target_report', entityId: report.id, beforeData: before, afterData: this.reportAuditView(report), metadata: { targetId, mediaCount: dto.mediaIds.length }, }, manager); return this.loadView(manager, targetId); }); } async submit( targetId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const { target, campaign } = await this.lockTargetContext(manager, targetId); this.assertCaptureAllowed(target, campaign, principal); const report = await this.lockReport(manager, targetId, true); if (!report) throw reportNotFound(); if (report.status !== SurveyReportStatus.DRAFT) { throw new ConflictException({ code: 'SURVEY_REPORT_NOT_DRAFT', message: 'Sólo se puede enviar un informe guardado como borrador', }); } await this.assertComplete(manager, report); const asset = await this.lockAsset(manager, target.assetId); const beforeAsset = this.assetAuditView(asset); asset.informationStatus = report.outcome === SurveyReportOutcome.NOT_LOCATED ? AssetInformationStatus.OBSERVED : AssetInformationStatus.SURVEYED; asset.dataOrigin = AssetDataOrigin.FIELD_SURVEY; asset.sourceName = `Relevamiento ${campaign.code}`; asset.sourceReference = `survey-report:${report.id}`; asset.sourceObservedAt = report.observedAt; asset.sourceNotes = report.notes; asset.provenanceVerifiedAt = null; asset.provenanceVerifiedBy = null; asset.provenanceUpdatedAt = new Date(); asset.provenanceUpdatedBy = principal.userId; asset.updatedBy = principal.userId; await manager.getRepository(Asset).save(asset); const assetVersion = await this.history.capture( manager, asset.id, AssetVersionChangeType.PROVENANCE_UPDATED, principal, request, ); asset.currentVersion = assetVersion; report.status = SurveyReportStatus.SUBMITTED; report.assetVersionAtSubmission = assetVersion; report.submittedAt = new Date(); report.submittedBy = principal.userId; report.reviewedAt = null; report.reviewedBy = null; report.reviewNotes = null; report.updatedBy = principal.userId; await manager.getRepository(SurveyTargetReport).save(report); target.status = SurveyTargetStatus.SUBMITTED; target.updatedBy = principal.userId; await manager.getRepository(SurveyCampaignTarget).save(target); campaign.updatedBy = principal.userId; await manager.getRepository(SurveyCampaign).save(campaign); const reportVersion = await this.captureReportVersion( manager, report, SurveyReportVersionEvent.SUBMITTED, principal, ); await this.recordAssetChange( manager, asset, beforeAsset, AuditAction.ASSET_PROVENANCE_UPDATED, principal, request, targetId, assetVersion, ); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.SURVEY_REPORT_SUBMITTED, entityType: 'survey_target_report', entityId: report.id, afterData: this.reportAuditView(report), metadata: { targetId, assetId: asset.id, assetVersion, reportVersion }, }, manager); return this.loadView(manager, targetId); }); } async review( targetId: string, dto: ReviewSurveyReportDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const { target, campaign } = await this.lockTargetContext(manager, targetId); if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) { throw new ConflictException({ code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', message: 'La campaña debe estar en curso para revisar informes', }); } const report = await this.lockReport(manager, targetId, true); if (!report) throw reportNotFound(); if (report.status !== SurveyReportStatus.SUBMITTED || target.status !== SurveyTargetStatus.SUBMITTED) { throw new ConflictException({ code: 'SURVEY_REPORT_NOT_SUBMITTED', message: 'El informe no está pendiente de revisión', }); } const notes = dto.notes?.trim() || null; if (dto.decision === SurveyReviewDecision.REJECT && (!notes || notes.length < 10)) { throw new BadRequestException({ code: 'SURVEY_REJECTION_REASON_REQUIRED', message: 'Indicá un motivo de rechazo de al menos 10 caracteres', }); } const asset = await this.lockAsset(manager, target.assetId); if ( dto.decision === SurveyReviewDecision.APPROVE && asset.currentVersion !== report.assetVersionAtSubmission ) { throw new ConflictException({ code: 'SURVEY_ASSET_CHANGED_AFTER_SUBMISSION', message: 'El activo cambió después del envío; rechazá el informe para que sea revisado nuevamente', }); } const beforeAsset = this.assetAuditView(asset); let assetVersion = asset.currentVersion; if (dto.decision === SurveyReviewDecision.APPROVE) { if (report.outcome !== SurveyReportOutcome.NOT_LOCATED) { asset.informationStatus = AssetInformationStatus.VALIDATED; asset.provenanceVerifiedAt = new Date(); asset.provenanceVerifiedBy = principal.userId; asset.provenanceUpdatedAt = new Date(); asset.provenanceUpdatedBy = principal.userId; asset.updatedBy = principal.userId; await manager.getRepository(Asset).save(asset); assetVersion = await this.history.capture( manager, asset.id, AssetVersionChangeType.PROVENANCE_VERIFIED, principal, request, ); asset.currentVersion = assetVersion; await this.recordAssetChange( manager, asset, beforeAsset, AuditAction.ASSET_PROVENANCE_VERIFIED, principal, request, targetId, assetVersion, ); } report.status = SurveyReportStatus.APPROVED; target.status = SurveyTargetStatus.COMPLETED; } else { const assetStillMatchesSubmission = asset.currentVersion === report.assetVersionAtSubmission; const assetNeedsUpdate = assetStillMatchesSubmission && ( asset.informationStatus !== AssetInformationStatus.OBSERVED || asset.provenanceVerifiedAt !== null || asset.provenanceVerifiedBy !== null ); if (assetNeedsUpdate) { asset.informationStatus = AssetInformationStatus.OBSERVED; asset.provenanceVerifiedAt = null; asset.provenanceVerifiedBy = null; asset.provenanceUpdatedAt = new Date(); asset.provenanceUpdatedBy = principal.userId; asset.updatedBy = principal.userId; await manager.getRepository(Asset).save(asset); assetVersion = await this.history.capture( manager, asset.id, AssetVersionChangeType.STATUS_CHANGED, principal, request, ); asset.currentVersion = assetVersion; await this.recordAssetChange( manager, asset, beforeAsset, AuditAction.ASSET_INFORMATION_STATUS_CHANGED, principal, request, targetId, assetVersion, ); } report.status = SurveyReportStatus.REJECTED; target.status = SurveyTargetStatus.IN_PROGRESS; } report.reviewedAt = new Date(); report.reviewedBy = principal.userId; report.reviewNotes = notes; report.updatedBy = principal.userId; await manager.getRepository(SurveyTargetReport).save(report); target.updatedBy = principal.userId; await manager.getRepository(SurveyCampaignTarget).save(target); campaign.updatedBy = principal.userId; await manager.getRepository(SurveyCampaign).save(campaign); const event = dto.decision === SurveyReviewDecision.APPROVE ? SurveyReportVersionEvent.APPROVED : SurveyReportVersionEvent.REJECTED; const reportVersion = await this.captureReportVersion(manager, report, event, principal); await this.audit.record({ ...administrationAuditContext(principal, request), action: dto.decision === SurveyReviewDecision.APPROVE ? AuditAction.SURVEY_REPORT_APPROVED : AuditAction.SURVEY_REPORT_REJECTED, entityType: 'survey_target_report', entityId: report.id, afterData: this.reportAuditView(report), metadata: { targetId, assetId: asset.id, assetVersion, reportVersion }, }, manager); return this.loadView(manager, targetId); }); } private async loadView(manager: EntityManager, targetId: string): Promise { const [context] = (await manager.query(` SELECT JSONB_BUILD_OBJECT( 'id', target.id, 'status', target.status, 'dueAt', target.due_at, 'instructions', target.instructions, 'assignedUser', CASE WHEN assignee.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', assignee.id, 'username', assignee.username, 'firstName', assignee.first_name, 'lastName', assignee.last_name ) END ) AS target, JSONB_BUILD_OBJECT( 'id', campaign.id, 'code', campaign.code, 'name', campaign.name, 'status', campaign.status ) AS campaign, JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'typeName', asset_type.name, 'informationStatus', asset.information_status, 'currentVersion', asset.current_version ) AS asset FROM survey_campaign_targets target INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id INNER JOIN assets asset ON asset.id = target.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN users assignee ON assignee.id = target.assigned_user_id WHERE target.id = $1 `, [targetId])) as ExecutionContextView[]; if (!context) throw targetNotFound(); const [report] = (await manager.query(` SELECT report.id, report.target_id AS "targetId", report.outcome, report.status, report.observed_at AS "observedAt", report.latitude::double precision AS latitude, report.longitude::double precision AS longitude, report.accuracy_m::double precision AS "accuracyM", report.notes, report.asset_version_at_submission AS "assetVersionAtSubmission", report.submitted_at AS "submittedAt", CASE WHEN submitter.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', submitter.id, 'username', submitter.username, 'firstName', submitter.first_name, 'lastName', submitter.last_name ) END AS "submittedBy", report.reviewed_at AS "reviewedAt", CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', reviewer.id, 'username', reviewer.username, 'firstName', reviewer.first_name, 'lastName', reviewer.last_name ) END AS "reviewedBy", report.review_notes AS "reviewNotes", COALESCE(( SELECT ARRAY_AGG(link.media_id ORDER BY link.created_at) FROM survey_target_report_media link INNER JOIN asset_media media ON media.id = link.media_id WHERE link.report_id = report.id AND link.included = true AND media.deleted_at IS NULL ), ARRAY[]::uuid[]) AS "selectedMediaIds", report.created_at AS "createdAt", report.updated_at AS "updatedAt" FROM survey_target_reports report LEFT JOIN users submitter ON submitter.id = report.submitted_by LEFT JOIN users reviewer ON reviewer.id = report.reviewed_by WHERE report.target_id = $1 `, [targetId])) as SurveyReportView[]; const availableMedia = (await manager.query(` SELECT media.id, media.kind, media.original_name AS "originalName", media.mime_type AS "mimeType", media.size_bytes::double precision AS "sizeBytes", media.sha256, media.title, media.description, media.captured_at AS "capturedAt", media.latitude::double precision AS latitude, media.longitude::double precision AS longitude, media.accuracy_m::double precision AS "accuracyM", media.source, media.created_at AS "createdAt", EXISTS ( SELECT 1 FROM survey_target_report_media link WHERE link.report_id = $2 AND link.media_id = media.id AND link.included = true ) AS selected FROM asset_media media WHERE media.asset_id = $1 AND media.kind = 'PHOTO' AND media.deleted_at IS NULL ORDER BY media.created_at DESC `, [context.asset.id, report?.id ?? null])) as ReportMediaView[]; const versions = report ? (await manager.query(` SELECT version.id, version.version_number AS "versionNumber", version.event, version.snapshot, version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername", version.created_at AS "createdAt" FROM survey_target_report_versions version WHERE version.report_id = $1 ORDER BY version.version_number DESC `, [report.id])) as ReportVersionView[] : []; return { ...context, report: report ?? null, availableMedia, versions }; } private async lockTargetContext( manager: EntityManager, targetId: string, ): Promise<{ target: SurveyCampaignTarget; campaign: SurveyCampaign }> { const target = await manager.getRepository(SurveyCampaignTarget) .createQueryBuilder('target') .where('target.id = :targetId', { targetId }) .setLock('pessimistic_write') .getOne(); if (!target) throw targetNotFound(); const campaign = await manager.getRepository(SurveyCampaign) .createQueryBuilder('campaign') .where('campaign.id = :campaignId', { campaignId: target.campaignId }) .setLock('pessimistic_write') .getOne(); if (!campaign) throw targetNotFound(); return { target, campaign }; } private async lockReport( manager: EntityManager, targetId: string, required: boolean, ): Promise { const report = await manager.getRepository(SurveyTargetReport) .createQueryBuilder('report') .where('report.targetId = :targetId', { targetId }) .setLock('pessimistic_write') .getOne(); if (!report && required) throw reportNotFound(); return report; } private async lockAsset(manager: EntityManager, assetId: string): Promise { const asset = await manager.getRepository(Asset) .createQueryBuilder('asset') .where('asset.id = :assetId', { assetId }) .setLock('pessimistic_write') .getOne(); if (!asset) throw targetNotFound(); return asset; } private assertCaptureAllowed( target: SurveyCampaignTarget, campaign: SurveyCampaign, principal: AuthPrincipal, ): void { if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS) { throw new ConflictException({ code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', message: 'La campaña debe estar en curso para capturar el relevamiento', }); } if (target.status !== SurveyTargetStatus.IN_PROGRESS) { throw new ConflictException({ code: 'SURVEY_TARGET_NOT_IN_PROGRESS', message: 'El objetivo debe estar en ejecución para editar o enviar el informe', }); } if ( !principal.permissions.includes('surveys.manage') && target.assignedUserId !== principal.userId ) { throw new ForbiddenException({ code: 'SURVEY_TARGET_NOT_ASSIGNED', message: 'El objetivo no está asignado al usuario actual', }); } } private assertCoordinatePair( latitude: number | null | undefined, longitude: number | null | undefined, accuracyM: number | null | undefined, ): void { if ((latitude == null) !== (longitude == null)) { throw new BadRequestException({ code: 'SURVEY_COORDINATES_INCOMPLETE', message: 'Latitud y longitud deben informarse juntas', }); } if (accuracyM != null && latitude == null) { throw new BadRequestException({ code: 'SURVEY_ACCURACY_WITHOUT_LOCATION', message: 'La precisión requiere coordenadas GPS', }); } } private async validateMedia( manager: EntityManager, assetId: string, mediaIds: string[], ): Promise { if (mediaIds.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS count FROM asset_media WHERE asset_id = $1 AND id = ANY($2::uuid[]) AND kind = 'PHOTO' AND deleted_at IS NULL `, [assetId, mediaIds])) as Array<{ count: number }>; if (Number(row?.count ?? 0) !== mediaIds.length) { throw new BadRequestException({ code: 'SURVEY_MEDIA_INVALID', message: 'Una o más evidencias no son fotografías activas del activo relevado', }); } } private async replaceMediaSelection( manager: EntityManager, reportId: string, mediaIds: string[], userId: string, ): Promise { await manager.query(` UPDATE survey_target_report_media SET included = false, updated_at = CURRENT_TIMESTAMP WHERE report_id = $1 AND included = true `, [reportId]); for (const mediaId of mediaIds) { await manager.query(` INSERT INTO survey_target_report_media ( report_id, media_id, included, added_by ) VALUES ($1, $2, true, $3) ON CONFLICT (report_id, media_id) DO UPDATE SET included = true, added_by = EXCLUDED.added_by, updated_at = CURRENT_TIMESTAMP `, [reportId, mediaId, userId]); } } private async assertComplete( manager: EntityManager, report: SurveyTargetReport, ): Promise { if (!report.outcome) { throw new BadRequestException({ code: 'SURVEY_OUTCOME_REQUIRED', message: 'Seleccioná el resultado de la verificación', }); } if (!report.observedAt) { throw new BadRequestException({ code: 'SURVEY_OBSERVED_AT_REQUIRED', message: 'Indicá la fecha y hora de observación', }); } if (report.latitude == null || report.longitude == null || report.accuracyM == null) { throw new BadRequestException({ code: 'SURVEY_GPS_REQUIRED', message: 'Capturá ubicación y precisión GPS antes de enviar', }); } if ( report.outcome !== SurveyReportOutcome.CONFIRMED && (!report.notes || report.notes.trim().length < 10) ) { throw new BadRequestException({ code: 'SURVEY_NOTES_REQUIRED', message: 'Describí los cambios o la imposibilidad de localizar el activo', }); } const [row] = (await manager.query(` SELECT COUNT(*)::integer AS count FROM survey_target_report_media link INNER JOIN asset_media media ON media.id = link.media_id WHERE link.report_id = $1 AND link.included = true AND media.kind = 'PHOTO' AND media.deleted_at IS NULL `, [report.id])) as Array<{ count: number }>; if (Number(row?.count ?? 0) < 1) { throw new BadRequestException({ code: 'SURVEY_PHOTO_REQUIRED', message: 'Seleccioná al menos una fotografía como evidencia', }); } } private async captureReportVersion( manager: EntityManager, report: SurveyTargetReport, event: SurveyReportVersionEvent, principal: AuthPrincipal, ): Promise { const [row] = (await manager.query(` SELECT COALESCE(MAX(version_number), 0)::integer + 1 AS "versionNumber" FROM survey_target_report_versions WHERE report_id = $1 `, [report.id])) as Array<{ versionNumber: number }>; const versionNumber = Number(row?.versionNumber ?? 1); const snapshot = await this.buildReportSnapshot(manager, report.id); await manager.query(` INSERT INTO survey_target_report_versions ( report_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [report.id, versionNumber, event, snapshot, principal.userId, principal.username]); return versionNumber; } private async buildReportSnapshot( manager: EntityManager, reportId: string, ): Promise> { const [row] = (await manager.query(` SELECT JSONB_BUILD_OBJECT( 'id', report.id, 'status', report.status, 'outcome', report.outcome, 'observedAt', report.observed_at, 'location', JSONB_BUILD_OBJECT( 'latitude', report.latitude, 'longitude', report.longitude, 'accuracyM', report.accuracy_m ), 'notes', report.notes, 'assetVersionAtSubmission', report.asset_version_at_submission, 'submittedAt', report.submitted_at, 'submittedBy', report.submitted_by, 'reviewedAt', report.reviewed_at, 'reviewedBy', report.reviewed_by, 'reviewNotes', report.review_notes, 'target', JSONB_BUILD_OBJECT( 'id', target.id, 'status', target.status, 'dueAt', target.due_at, 'instructions', target.instructions ), 'campaign', JSONB_BUILD_OBJECT( 'id', campaign.id, 'code', campaign.code, 'name', campaign.name ), 'asset', JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'informationStatus', asset.information_status, 'currentVersion', asset.current_version ), 'evidence', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', media.id, 'originalName', media.original_name, 'mimeType', media.mime_type, 'sizeBytes', media.size_bytes, 'sha256', media.sha256, 'title', media.title, 'description', media.description, 'capturedAt', media.captured_at, 'latitude', media.latitude, 'longitude', media.longitude, 'accuracyM', media.accuracy_m, 'source', media.source, 'createdAt', media.created_at ) ORDER BY media.created_at, media.id) FROM survey_target_report_media link INNER JOIN asset_media media ON media.id = link.media_id WHERE link.report_id = report.id AND link.included = true ), '[]'::jsonb) ) AS snapshot FROM survey_target_reports report INNER JOIN survey_campaign_targets target ON target.id = report.target_id INNER JOIN survey_campaigns campaign ON campaign.id = target.campaign_id INNER JOIN assets asset ON asset.id = target.asset_id WHERE report.id = $1 `, [reportId])) as Array<{ snapshot: Record }>; if (!row) throw reportNotFound(); return row.snapshot; } private async recordAssetChange( manager: EntityManager, asset: Asset, beforeData: Record, action: AuditAction, principal: AuthPrincipal, request: RequestWithContext, targetId: string, versionNumber: number, ): Promise { await this.audit.record({ ...administrationAuditContext(principal, request), action, entityType: 'asset', entityId: asset.id, beforeData, afterData: this.assetAuditView(asset), metadata: { targetId, versionNumber, source: 'survey_report' }, }, manager); } private assetAuditView(asset: Asset): Record { return { informationStatus: asset.informationStatus, dataOrigin: asset.dataOrigin, sourceName: asset.sourceName, sourceReference: asset.sourceReference, sourceObservedAt: asset.sourceObservedAt, provenanceVerifiedAt: asset.provenanceVerifiedAt, provenanceVerifiedBy: asset.provenanceVerifiedBy, currentVersion: asset.currentVersion, }; } private reportAuditView(report: SurveyTargetReport): Record { return { targetId: report.targetId, outcome: report.outcome, status: report.status, observedAt: report.observedAt, latitude: this.numberOrNull(report.latitude), longitude: this.numberOrNull(report.longitude), accuracyM: this.numberOrNull(report.accuracyM), notes: report.notes, assetVersionAtSubmission: report.assetVersionAtSubmission, submittedAt: report.submittedAt, submittedBy: report.submittedBy, reviewedAt: report.reviewedAt, reviewedBy: report.reviewedBy, reviewNotes: report.reviewNotes, }; } private numberOrNull(value: string | number | null): number | null { return value == null ? null : Number(value); } }