F4: remove legacy survey execution service
This commit is contained in:
@@ -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<string, unknown>;
|
|
||||||
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<SurveyExecutionView> {
|
|
||||||
return this.dataSource.transaction((manager) => this.loadView(manager, targetId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(
|
|
||||||
targetId: string,
|
|
||||||
dto: SaveSurveyReportDto,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
): Promise<SurveyExecutionView> {
|
|
||||||
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<SurveyExecutionView> {
|
|
||||||
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<SurveyExecutionView> {
|
|
||||||
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<SurveyExecutionView> {
|
|
||||||
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<SurveyTargetReport | null> {
|
|
||||||
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<Asset> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<number> {
|
|
||||||
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<Record<string, unknown>> {
|
|
||||||
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<string, unknown> }>;
|
|
||||||
if (!row) throw reportNotFound();
|
|
||||||
return row.snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async recordAssetChange(
|
|
||||||
manager: EntityManager,
|
|
||||||
asset: Asset,
|
|
||||||
beforeData: Record<string, unknown>,
|
|
||||||
action: AuditAction,
|
|
||||||
principal: AuthPrincipal,
|
|
||||||
request: RequestWithContext,
|
|
||||||
targetId: string,
|
|
||||||
versionNumber: number,
|
|
||||||
): Promise<void> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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<string, unknown> {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user