From 46c2abb4d1c9edcd4f4b2f901d1b514705a5d833 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 06:27:10 -0300 Subject: [PATCH] F4: remove legacy survey execution service --- .../survey-execution.service.ts | 924 ------------------ 1 file changed, 924 deletions(-) delete mode 100644 api-v3/src/survey-execution/survey-execution.service.ts diff --git a/api-v3/src/survey-execution/survey-execution.service.ts b/api-v3/src/survey-execution/survey-execution.service.ts deleted file mode 100644 index a0d16a8..0000000 --- a/api-v3/src/survey-execution/survey-execution.service.ts +++ /dev/null @@ -1,924 +0,0 @@ -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); - } -}