From f1dbb75834008733947ae9752cee94599ecc5e88 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:07:23 -0300 Subject: [PATCH] =?UTF-8?q?F4.6=20=C2=B7=20eliminar=20c=C3=B3digo=20muerto?= =?UTF-8?q?=20de=20Campa=C3=B1as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/survey-campaign-target.entity.ts | 44 - .../entities/survey-campaign.entity.ts | 51 - .../survey-target-report-media.entity.ts | 19 - .../survey-target-report-version.entity.ts | 36 - .../entities/survey-target-report.entity.ts | 73 -- .../dto/review-survey-report.dto.ts | 16 - .../dto/save-survey-report.dto.ts | 58 -- .../survey-execution.controller.ts | 61 -- .../survey-execution.module.ts | 12 - .../survey-execution.service.ts | 924 ------------------ .../dto/add-survey-target.dto.ts | 23 - .../dto/assign-survey-target.dto.ts | 7 - .../dto/change-survey-campaign-status.dto.ts | 7 - .../dto/change-survey-target-status.dto.ts | 7 - .../dto/create-survey-campaign.dto.ts | 51 - .../dto/list-survey-campaigns-query.dto.ts | 40 - .../dto/update-survey-campaign.dto.ts | 53 - .../dto/update-survey-target.dto.ts | 16 - .../survey-campaigns.controller.ts | 131 --- .../survey-planning/survey-planning.module.ts | 14 - .../survey-planning.service.ts | 791 --------------- 21 files changed, 2434 deletions(-) delete mode 100644 api-v3/src/database/entities/survey-campaign-target.entity.ts delete mode 100644 api-v3/src/database/entities/survey-campaign.entity.ts delete mode 100644 api-v3/src/database/entities/survey-target-report-media.entity.ts delete mode 100644 api-v3/src/database/entities/survey-target-report-version.entity.ts delete mode 100644 api-v3/src/database/entities/survey-target-report.entity.ts delete mode 100644 api-v3/src/survey-execution/dto/review-survey-report.dto.ts delete mode 100644 api-v3/src/survey-execution/dto/save-survey-report.dto.ts delete mode 100644 api-v3/src/survey-execution/survey-execution.controller.ts delete mode 100644 api-v3/src/survey-execution/survey-execution.module.ts delete mode 100644 api-v3/src/survey-execution/survey-execution.service.ts delete mode 100644 api-v3/src/survey-planning/dto/add-survey-target.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/assign-survey-target.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts delete mode 100644 api-v3/src/survey-planning/dto/update-survey-target.dto.ts delete mode 100644 api-v3/src/survey-planning/survey-campaigns.controller.ts delete mode 100644 api-v3/src/survey-planning/survey-planning.module.ts delete mode 100644 api-v3/src/survey-planning/survey-planning.service.ts diff --git a/api-v3/src/database/entities/survey-campaign-target.entity.ts b/api-v3/src/database/entities/survey-campaign-target.entity.ts deleted file mode 100644 index f944fbb..0000000 --- a/api-v3/src/database/entities/survey-campaign-target.entity.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyTargetStatus { - PENDING = 'PENDING', - IN_PROGRESS = 'IN_PROGRESS', - SUBMITTED = 'SUBMITTED', - COMPLETED = 'COMPLETED', - SKIPPED = 'SKIPPED', -} - -@Entity({ name: 'survey_campaign_targets' }) -@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true }) -@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status']) -@Index('idx_survey_campaign_targets_asset_id', ['assetId']) -@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId']) -export class SurveyCampaignTarget extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'campaign_id', type: 'uuid' }) - campaignId!: string; - - @Column({ name: 'asset_id', type: 'uuid' }) - assetId!: string; - - @Column({ name: 'assigned_user_id', type: 'uuid', nullable: true }) - assignedUserId!: string | null; - - @Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING }) - status!: SurveyTargetStatus; - - @Column({ name: 'due_at', type: 'timestamptz', nullable: true }) - dueAt!: Date | null; - - @Column({ type: 'text', nullable: true }) - instructions!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-campaign.entity.ts b/api-v3/src/database/entities/survey-campaign.entity.ts deleted file mode 100644 index a6c536d..0000000 --- a/api-v3/src/database/entities/survey-campaign.entity.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyCampaignStatus { - DRAFT = 'DRAFT', - PLANNED = 'PLANNED', - IN_PROGRESS = 'IN_PROGRESS', - COMPLETED = 'COMPLETED', - CANCELLED = 'CANCELLED', -} - -@Entity({ name: 'survey_campaigns' }) -@Index('uq_survey_campaigns_code', ['code'], { unique: true }) -@Index('idx_survey_campaigns_status', ['status']) -@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId']) -@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId']) -@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt']) -export class SurveyCampaign extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ type: 'varchar', length: 80 }) - code!: string; - - @Column({ type: 'varchar', length: 200 }) - name!: string; - - @Column({ type: 'text', nullable: true }) - description!: string | null; - - @Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT }) - status!: SurveyCampaignStatus; - - @Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true }) - plannedStartAt!: Date | null; - - @Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true }) - plannedEndAt!: Date | null; - - @Column({ name: 'scope_asset_id', type: 'uuid', nullable: true }) - scopeAssetId!: string | null; - - @Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true }) - coordinatorUserId!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-target-report-media.entity.ts b/api-v3/src/database/entities/survey-target-report-media.entity.ts deleted file mode 100644 index 66dfadb..0000000 --- a/api-v3/src/database/entities/survey-target-report-media.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -@Entity({ name: 'survey_target_report_media' }) -@Index('idx_survey_target_report_media_media_id', ['mediaId']) -@Index('idx_survey_target_report_media_included', ['reportId', 'included']) -export class SurveyTargetReportMedia extends TimestampedEntity { - @PrimaryColumn({ name: 'report_id', type: 'uuid' }) - reportId!: string; - - @PrimaryColumn({ name: 'media_id', type: 'uuid' }) - mediaId!: string; - - @Column({ type: 'boolean', default: true }) - included!: boolean; - - @Column({ name: 'added_by', type: 'uuid', nullable: true }) - addedBy!: string | null; -} diff --git a/api-v3/src/database/entities/survey-target-report-version.entity.ts b/api-v3/src/database/entities/survey-target-report-version.entity.ts deleted file mode 100644 index b32a790..0000000 --- a/api-v3/src/database/entities/survey-target-report-version.entity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; - -export enum SurveyReportVersionEvent { - SUBMITTED = 'SUBMITTED', - APPROVED = 'APPROVED', - REJECTED = 'REJECTED', -} - -@Entity({ name: 'survey_target_report_versions' }) -@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true }) -@Index('idx_survey_target_report_versions_created_at', ['createdAt']) -export class SurveyTargetReportVersion { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'report_id', type: 'uuid' }) - reportId!: string; - - @Column({ name: 'version_number', type: 'integer' }) - versionNumber!: number; - - @Column({ type: 'varchar', length: 24 }) - event!: SurveyReportVersionEvent; - - @Column({ type: 'jsonb' }) - snapshot!: Record; - - @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) - actorUserId!: string | null; - - @Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true }) - actorUsername!: string | null; - - @Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) - createdAt!: Date; -} diff --git a/api-v3/src/database/entities/survey-target-report.entity.ts b/api-v3/src/database/entities/survey-target-report.entity.ts deleted file mode 100644 index b835063..0000000 --- a/api-v3/src/database/entities/survey-target-report.entity.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; -import { TimestampedEntity } from './timestamped.entity'; - -export enum SurveyReportOutcome { - CONFIRMED = 'CONFIRMED', - CHANGES_RECORDED = 'CHANGES_RECORDED', - NOT_LOCATED = 'NOT_LOCATED', -} - -export enum SurveyReportStatus { - DRAFT = 'DRAFT', - SUBMITTED = 'SUBMITTED', - APPROVED = 'APPROVED', - REJECTED = 'REJECTED', -} - -@Entity({ name: 'survey_target_reports' }) -@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true }) -@Index('idx_survey_target_reports_status', ['status']) -@Index('idx_survey_target_reports_submitted_by', ['submittedBy']) -@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy']) -export class SurveyTargetReport extends TimestampedEntity { - @PrimaryGeneratedColumn('uuid') - id!: string; - - @Column({ name: 'target_id', type: 'uuid' }) - targetId!: string; - - @Column({ type: 'varchar', length: 32, nullable: true }) - outcome!: SurveyReportOutcome | null; - - @Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT }) - status!: SurveyReportStatus; - - @Column({ name: 'observed_at', type: 'timestamptz', nullable: true }) - observedAt!: Date | null; - - @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) - latitude!: string | null; - - @Column({ type: 'numeric', precision: 9, scale: 6, nullable: true }) - longitude!: string | null; - - @Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true }) - accuracyM!: string | null; - - @Column({ type: 'text', nullable: true }) - notes!: string | null; - - @Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true }) - assetVersionAtSubmission!: number | null; - - @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) - submittedAt!: Date | null; - - @Column({ name: 'submitted_by', type: 'uuid', nullable: true }) - submittedBy!: string | null; - - @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) - reviewedAt!: Date | null; - - @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) - reviewedBy!: string | null; - - @Column({ name: 'review_notes', type: 'text', nullable: true }) - reviewNotes!: string | null; - - @Column({ name: 'created_by', type: 'uuid', nullable: true }) - createdBy!: string | null; - - @Column({ name: 'updated_by', type: 'uuid', nullable: true }) - updatedBy!: string | null; -} diff --git a/api-v3/src/survey-execution/dto/review-survey-report.dto.ts b/api-v3/src/survey-execution/dto/review-survey-report.dto.ts deleted file mode 100644 index cf878fb..0000000 --- a/api-v3/src/survey-execution/dto/review-survey-report.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; - -export enum SurveyReviewDecision { - APPROVE = 'APPROVE', - REJECT = 'REJECT', -} - -export class ReviewSurveyReportDto { - @IsEnum(SurveyReviewDecision) - decision!: SurveyReviewDecision; - - @IsOptional() - @IsString() - @MaxLength(4000) - notes?: string | null; -} diff --git a/api-v3/src/survey-execution/dto/save-survey-report.dto.ts b/api-v3/src/survey-execution/dto/save-survey-report.dto.ts deleted file mode 100644 index 74f1cb0..0000000 --- a/api-v3/src/survey-execution/dto/save-survey-report.dto.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Type } from 'class-transformer'; -import { - ArrayMaxSize, - ArrayUnique, - IsArray, - IsEnum, - IsISO8601, - IsNumber, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; -import { SurveyReportOutcome } from '../../database/entities'; - -export class SaveSurveyReportDto { - @IsOptional() - @IsEnum(SurveyReportOutcome) - outcome?: SurveyReportOutcome | null; - - @IsOptional() - @IsISO8601({ strict: true }) - observedAt?: string | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 6 }) - @Min(-90) - @Max(90) - latitude?: number | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 6 }) - @Min(-180) - @Max(180) - longitude?: number | null; - - @IsOptional() - @Type(() => Number) - @IsNumber({ maxDecimalPlaces: 3 }) - @Min(0) - @Max(100000) - accuracyM?: number | null; - - @IsOptional() - @IsString() - @MaxLength(8000) - notes?: string | null; - - @IsArray() - @ArrayMaxSize(50) - @ArrayUnique() - @IsUUID('4', { each: true }) - mediaIds!: string[]; -} diff --git a/api-v3/src/survey-execution/survey-execution.controller.ts b/api-v3/src/survey-execution/survey-execution.controller.ts deleted file mode 100644 index 8d8790c..0000000 --- a/api-v3/src/survey-execution/survey-execution.controller.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, - Put, - Req, -} from '@nestjs/common'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; -import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { ReviewSurveyReportDto } from './dto/review-survey-report.dto'; -import { SaveSurveyReportDto } from './dto/save-survey-report.dto'; -import { SurveyExecutionService } from './survey-execution.service'; - -@Controller('survey-campaign-targets/:targetId/report') -export class SurveyExecutionController { - constructor(private readonly execution: SurveyExecutionService) {} - - @Get() - @RequirePermissions('surveys.read_reports') - get( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - ) { - return this.execution.get(targetId); - } - - @Put() - @RequirePermissions('surveys.capture') - save( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @Body() dto: SaveSurveyReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.save(targetId, dto, principal, request); - } - - @Post('submit') - @RequirePermissions('surveys.capture') - submit( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.submit(targetId, principal, request); - } - - @Post('review') - @RequirePermissions('surveys.review') - review( - @Param('targetId', new ParseUUIDPipe({ version: '4' })) targetId: string, - @Body() dto: ReviewSurveyReportDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.execution.review(targetId, dto, principal, request); - } -} diff --git a/api-v3/src/survey-execution/survey-execution.module.ts b/api-v3/src/survey-execution/survey-execution.module.ts deleted file mode 100644 index 6e0bf58..0000000 --- a/api-v3/src/survey-execution/survey-execution.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AssetMasterModule } from '../asset-master/asset-master.module'; -import { AuditModule } from '../audit/audit.module'; -import { SurveyExecutionController } from './survey-execution.controller'; -import { SurveyExecutionService } from './survey-execution.service'; - -@Module({ - imports: [AuditModule, AssetMasterModule], - controllers: [SurveyExecutionController], - providers: [SurveyExecutionService], -}) -export class SurveyExecutionModule {} 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); - } -} diff --git a/api-v3/src/survey-planning/dto/add-survey-target.dto.ts b/api-v3/src/survey-planning/dto/add-survey-target.dto.ts deleted file mode 100644 index 1842129..0000000 --- a/api-v3/src/survey-planning/dto/add-survey-target.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Transform } from 'class-transformer'; -import { IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; - -export class AddSurveyTargetDto { - @IsUUID('4') - assetId!: string; - - @IsOptional() - @IsUUID('4') - assignedUserId?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - dueAt?: string | null; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - instructions?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts b/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts deleted file mode 100644 index 84bf835..0000000 --- a/api-v3/src/survey-planning/dto/assign-survey-target.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsOptional, IsUUID } from 'class-validator'; - -export class AssignSurveyTargetDto { - @IsOptional() - @IsUUID('4') - assignedUserId!: string | null; -} diff --git a/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts deleted file mode 100644 index 253e16e..0000000 --- a/api-v3/src/survey-planning/dto/change-survey-campaign-status.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { SurveyCampaignStatus } from '../../database/entities'; - -export class ChangeSurveyCampaignStatusDto { - @IsEnum(SurveyCampaignStatus) - status!: SurveyCampaignStatus; -} diff --git a/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts b/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts deleted file mode 100644 index 760b817..0000000 --- a/api-v3/src/survey-planning/dto/change-survey-target-status.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { SurveyTargetStatus } from '../../database/entities'; - -export class ChangeSurveyTargetStatusDto { - @IsEnum(SurveyTargetStatus) - status!: SurveyTargetStatus; -} diff --git a/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts deleted file mode 100644 index dcea6a9..0000000 --- a/api-v3/src/survey-planning/dto/create-survey-campaign.dto.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Transform } from 'class-transformer'; -import { - IsISO8601, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; - -export class CreateSurveyCampaignDto { - @Transform(({ value }) => - typeof value === 'string' ? value.trim().toUpperCase() : value, - ) - @IsString() - @MinLength(1) - @MaxLength(80) - @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) - code!: string; - - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MinLength(1) - @MaxLength(200) - name!: string; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - description?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedStartAt?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedEndAt?: string | null; - - @IsOptional() - @IsUUID('4') - scopeAssetId?: string | null; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts b/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts deleted file mode 100644 index fa37549..0000000 --- a/api-v3/src/survey-planning/dto/list-survey-campaigns-query.dto.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Type } from 'class-transformer'; -import { - IsEnum, - IsInt, - IsOptional, - IsString, - IsUUID, - Max, - MaxLength, - Min, -} from 'class-validator'; -import { SurveyCampaignStatus } from '../../database/entities'; - -export class ListSurveyCampaignsQueryDto { - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - page = 1; - - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - @Max(100) - pageSize = 25; - - @IsOptional() - @IsString() - @MaxLength(200) - search?: string; - - @IsOptional() - @IsEnum(SurveyCampaignStatus) - status?: SurveyCampaignStatus; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string; -} diff --git a/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts b/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts deleted file mode 100644 index 5beed45..0000000 --- a/api-v3/src/survey-planning/dto/update-survey-campaign.dto.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Transform } from 'class-transformer'; -import { - IsISO8601, - IsOptional, - IsString, - IsUUID, - Matches, - MaxLength, - MinLength, -} from 'class-validator'; - -export class UpdateSurveyCampaignDto { - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' ? value.trim().toUpperCase() : value, - ) - @IsString() - @MinLength(1) - @MaxLength(80) - @Matches(/^[A-Z0-9][A-Z0-9._/-]*$/) - code?: string; - - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) - @IsString() - @MinLength(1) - @MaxLength(200) - name?: string; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - description?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedStartAt?: string | null; - - @IsOptional() - @IsISO8601({ strict: true }) - plannedEndAt?: string | null; - - @IsOptional() - @IsUUID('4') - scopeAssetId?: string | null; - - @IsOptional() - @IsUUID('4') - coordinatorUserId?: string | null; -} diff --git a/api-v3/src/survey-planning/dto/update-survey-target.dto.ts b/api-v3/src/survey-planning/dto/update-survey-target.dto.ts deleted file mode 100644 index b3c9acc..0000000 --- a/api-v3/src/survey-planning/dto/update-survey-target.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Transform } from 'class-transformer'; -import { IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator'; - -export class UpdateSurveyTargetDto { - @IsOptional() - @IsISO8601({ strict: true }) - dueAt?: string | null; - - @IsOptional() - @Transform(({ value }) => - typeof value === 'string' && value.trim() ? value.trim() : null, - ) - @IsString() - @MaxLength(4000) - instructions?: string | null; -} diff --git a/api-v3/src/survey-planning/survey-campaigns.controller.ts b/api-v3/src/survey-planning/survey-campaigns.controller.ts deleted file mode 100644 index a1a506a..0000000 --- a/api-v3/src/survey-planning/survey-campaigns.controller.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Patch, - Post, - Put, - Query, - Req, -} from '@nestjs/common'; -import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; -import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; -import type { - AuthPrincipal, - RequestWithContext, -} from '../common/http/request-context'; -import { AddSurveyTargetDto } from './dto/add-survey-target.dto'; -import { AssignSurveyTargetDto } from './dto/assign-survey-target.dto'; -import { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto'; -import { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto'; -import { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto'; -import { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto'; -import { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto'; -import { UpdateSurveyTargetDto } from './dto/update-survey-target.dto'; -import { SurveyPlanningService } from './survey-planning.service'; - -@Controller('survey-campaigns') -export class SurveyCampaignsController { - constructor(private readonly planning: SurveyPlanningService) {} - - @Get() - @RequirePermissions('surveys.read') - list(@Query() query: ListSurveyCampaignsQueryDto) { - return this.planning.list(query); - } - - @Get('assignees') - @RequirePermissions('surveys.assign') - assignees() { - return this.planning.listAssignees(); - } - - @Post() - @RequirePermissions('surveys.manage') - create( - @Body() dto: CreateSurveyCampaignDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.create(dto, principal, request); - } - - @Get(':id') - @RequirePermissions('surveys.read') - get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { - return this.planning.getById(id); - } - - @Patch(':id') - @RequirePermissions('surveys.manage') - update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateSurveyCampaignDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.update(id, dto, principal, request); - } - - @Put(':id/status') - @RequirePermissions('surveys.manage') - changeStatus( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeSurveyCampaignStatusDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.changeCampaignStatus(id, dto, principal, request); - } - - @Post(':id/targets') - @RequirePermissions('surveys.manage') - addTarget( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: AddSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.addTarget(id, dto, principal, request); - } -} - -@Controller('survey-campaign-targets') -export class SurveyCampaignTargetsController { - constructor(private readonly planning: SurveyPlanningService) {} - - @Patch(':id') - @RequirePermissions('surveys.manage') - update( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: UpdateSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.updateTarget(id, dto, principal, request); - } - - @Put(':id/assignment') - @RequirePermissions('surveys.assign') - assign( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: AssignSurveyTargetDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.assignTarget(id, dto, principal, request); - } - - @Put(':id/status') - @RequirePermissions('surveys.execute') - changeStatus( - @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, - @Body() dto: ChangeSurveyTargetStatusDto, - @CurrentAuth() principal: AuthPrincipal, - @Req() request: RequestWithContext, - ) { - return this.planning.changeTargetStatus(id, dto, principal, request); - } -} diff --git a/api-v3/src/survey-planning/survey-planning.module.ts b/api-v3/src/survey-planning/survey-planning.module.ts deleted file mode 100644 index 42a8cf6..0000000 --- a/api-v3/src/survey-planning/survey-planning.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuditModule } from '../audit/audit.module'; -import { - SurveyCampaignsController, - SurveyCampaignTargetsController, -} from './survey-campaigns.controller'; -import { SurveyPlanningService } from './survey-planning.service'; - -@Module({ - imports: [AuditModule], - controllers: [SurveyCampaignsController, SurveyCampaignTargetsController], - providers: [SurveyPlanningService], -}) -export class SurveyPlanningModule {} diff --git a/api-v3/src/survey-planning/survey-planning.service.ts b/api-v3/src/survey-planning/survey-planning.service.ts deleted file mode 100644 index 7a4a111..0000000 --- a/api-v3/src/survey-planning/survey-planning.service.ts +++ /dev/null @@ -1,791 +0,0 @@ -import { - BadRequestException, - ConflictException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { DataSource, EntityManager } from 'typeorm'; -import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; -import { AuditService } from '../audit/audit.service'; -import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; -import { - AuditAction, - SurveyCampaign, - SurveyCampaignStatus, - SurveyCampaignTarget, - SurveyTargetStatus, -} from '../database/entities'; -import type { AddSurveyTargetDto } from './dto/add-survey-target.dto'; -import type { AssignSurveyTargetDto } from './dto/assign-survey-target.dto'; -import type { ChangeSurveyCampaignStatusDto } from './dto/change-survey-campaign-status.dto'; -import type { ChangeSurveyTargetStatusDto } from './dto/change-survey-target-status.dto'; -import type { CreateSurveyCampaignDto } from './dto/create-survey-campaign.dto'; -import type { ListSurveyCampaignsQueryDto } from './dto/list-survey-campaigns-query.dto'; -import type { UpdateSurveyCampaignDto } from './dto/update-survey-campaign.dto'; -import type { UpdateSurveyTargetDto } from './dto/update-survey-target.dto'; - -export interface PersonSummary { - id: string; - username: string; - firstName: string; - lastName: string; -} - -interface AssetSummary { - id: string; - code: string; - name: string; -} - -export interface SurveyCampaignListItem { - id: string; - code: string; - name: string; - description: string | null; - status: SurveyCampaignStatus; - plannedStartAt: Date | null; - plannedEndAt: Date | null; - scopeAsset: AssetSummary | null; - coordinator: PersonSummary | null; - targetCount: number; - pendingCount: number; - inProgressCount: number; - submittedCount: number; - completedCount: number; - skippedCount: number; - createdAt: Date; - updatedAt: Date; -} - -export interface SurveyTargetView { - id: string; - campaignId: string; - asset: AssetSummary & { typeName: string }; - assignedUser: PersonSummary | null; - status: SurveyTargetStatus; - dueAt: Date | null; - instructions: string | null; - createdAt: Date; - updatedAt: Date; -} - -export interface SurveyCampaignView extends SurveyCampaignListItem { - targets: SurveyTargetView[]; -} - -const campaignTransitions: Record = { - [SurveyCampaignStatus.DRAFT]: [SurveyCampaignStatus.PLANNED, SurveyCampaignStatus.CANCELLED], - [SurveyCampaignStatus.PLANNED]: [ - SurveyCampaignStatus.DRAFT, - SurveyCampaignStatus.IN_PROGRESS, - SurveyCampaignStatus.CANCELLED, - ], - [SurveyCampaignStatus.IN_PROGRESS]: [ - SurveyCampaignStatus.COMPLETED, - SurveyCampaignStatus.CANCELLED, - ], - [SurveyCampaignStatus.COMPLETED]: [], - [SurveyCampaignStatus.CANCELLED]: [], -}; - -const targetTransitions: Record = { - [SurveyTargetStatus.PENDING]: [SurveyTargetStatus.IN_PROGRESS, SurveyTargetStatus.SKIPPED], - [SurveyTargetStatus.IN_PROGRESS]: [ - SurveyTargetStatus.PENDING, - SurveyTargetStatus.SKIPPED, - ], - [SurveyTargetStatus.SUBMITTED]: [], - [SurveyTargetStatus.COMPLETED]: [], - [SurveyTargetStatus.SKIPPED]: [], -}; - -function campaignNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_CAMPAIGN_NOT_FOUND', - message: 'Campaña de relevamiento no encontrada', - }); -} - -function targetNotFound(): NotFoundException { - return new NotFoundException({ - code: 'SURVEY_TARGET_NOT_FOUND', - message: 'Objetivo de relevamiento no encontrado', - }); -} - -function dateValue(value: string | null | undefined): Date | null { - return value ? new Date(value) : null; -} - -@Injectable() -export class SurveyPlanningService { - constructor( - private readonly dataSource: DataSource, - private readonly audit: AuditService, - ) {} - - async list(query: ListSurveyCampaignsQueryDto) { - const conditions: string[] = []; - const parameters: unknown[] = []; - const addParameter = (value: unknown) => { - parameters.push(value); - return `$${parameters.length}`; - }; - - if (query.search?.trim()) { - const search = addParameter(`%${query.search.trim()}%`); - conditions.push(`(campaign.code ILIKE ${search} OR campaign.name ILIKE ${search})`); - } - if (query.status) conditions.push(`campaign.status = ${addParameter(query.status)}`); - if (query.coordinatorUserId) { - conditions.push(`campaign.coordinator_user_id = ${addParameter(query.coordinatorUserId)}`); - } - - const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; - const [countRow] = (await this.dataSource.query( - `SELECT COUNT(*)::integer AS total FROM survey_campaigns campaign ${where}`, - parameters, - )) as Array<{ total: number }>; - const total = Number(countRow?.total ?? 0); - const offset = (query.page - 1) * query.pageSize; - const limitParameter = addParameter(query.pageSize); - const offsetParameter = addParameter(offset); - const data = (await this.dataSource.query( - `${this.campaignSelect(where)} - ORDER BY campaign.updated_at DESC, campaign.code ASC - LIMIT ${limitParameter} OFFSET ${offsetParameter}`, - parameters, - )) as SurveyCampaignListItem[]; - - return { - data, - meta: { - page: query.page, - pageSize: query.pageSize, - total, - totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), - }, - }; - } - - async listAssignees(): Promise<{ data: PersonSummary[] }> { - const data = (await this.dataSource.query(` - SELECT DISTINCT - user_account.id, - user_account.username, - user_account.first_name AS "firstName", - user_account.last_name AS "lastName" - FROM users user_account - INNER JOIN user_roles user_role ON user_role.user_id = user_account.id - INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id - INNER JOIN permissions permission ON permission.id = role_permission.permission_id - WHERE user_account.status = 'ACTIVE' - AND permission.code = 'surveys.execute' - ORDER BY user_account.last_name, user_account.first_name, user_account.username - `)) as PersonSummary[]; - return { data }; - } - - async getById(id: string): Promise { - return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id)); - } - - async create( - dto: CreateSurveyCampaignDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - try { - return await this.dataSource.transaction(async (manager) => { - await this.validateDates(dto.plannedStartAt ?? null, dto.plannedEndAt ?? null); - await this.requireAsset(manager, dto.scopeAssetId ?? null); - await this.requireActiveUser(manager, dto.coordinatorUserId ?? null); - const campaign = manager.getRepository(SurveyCampaign).create({ - code: dto.code, - name: dto.name, - description: dto.description ?? null, - status: SurveyCampaignStatus.DRAFT, - plannedStartAt: dateValue(dto.plannedStartAt), - plannedEndAt: dateValue(dto.plannedEndAt), - scopeAssetId: dto.scopeAssetId ?? null, - coordinatorUserId: dto.coordinatorUserId ?? null, - createdBy: principal.userId, - updatedBy: principal.userId, - }); - await manager.getRepository(SurveyCampaign).save(campaign); - const created = await this.loadCampaignView(manager, campaign.id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_CREATED, - entityType: 'survey_campaign', - entityId: campaign.id, - afterData: this.auditCampaign(created), - }, manager); - return created; - }); - } catch (error) { - if (isUniqueViolation(error)) throw this.campaignConflict(); - throw error; - } - } - - async update( - id: string, - dto: UpdateSurveyCampaignDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (Object.keys(dto).length === 0) { - throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); - } - try { - return await this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, id); - this.assertCampaignEditable(campaign); - const before = await this.loadCampaignView(manager, id); - const nextStart = dto.plannedStartAt === undefined - ? campaign.plannedStartAt - : dateValue(dto.plannedStartAt); - const nextEnd = dto.plannedEndAt === undefined - ? campaign.plannedEndAt - : dateValue(dto.plannedEndAt); - await this.validateDates(nextStart, nextEnd); - if (dto.scopeAssetId !== undefined) { - await this.requireAsset(manager, dto.scopeAssetId); - await this.assertCampaignTargetsInScope(manager, id, dto.scopeAssetId); - } - if (dto.coordinatorUserId !== undefined) { - await this.requireActiveUser(manager, dto.coordinatorUserId); - } - - if (dto.code !== undefined) campaign.code = dto.code; - if (dto.name !== undefined) campaign.name = dto.name; - if (dto.description !== undefined) campaign.description = dto.description; - if (dto.plannedStartAt !== undefined) campaign.plannedStartAt = nextStart; - if (dto.plannedEndAt !== undefined) campaign.plannedEndAt = nextEnd; - if (dto.scopeAssetId !== undefined) campaign.scopeAssetId = dto.scopeAssetId; - if (dto.coordinatorUserId !== undefined) { - campaign.coordinatorUserId = dto.coordinatorUserId; - } - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadCampaignView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_UPDATED, - entityType: 'survey_campaign', - entityId: id, - beforeData: this.auditCampaign(before), - afterData: this.auditCampaign(updated), - }, manager); - return updated; - }); - } catch (error) { - if (isUniqueViolation(error)) throw this.campaignConflict(); - throw error; - } - } - - async changeCampaignStatus( - id: string, - dto: ChangeSurveyCampaignStatusDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, id); - if (campaign.status === dto.status) return this.loadCampaignView(manager, id); - if (!campaignTransitions[campaign.status].includes(dto.status)) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_INVALID_TRANSITION', - message: `No se puede pasar la campaña de ${campaign.status} a ${dto.status}`, - }); - } - const [counts] = (await manager.query(` - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE status IN ('PENDING', 'IN_PROGRESS', 'SUBMITTED'))::integer AS outstanding - FROM survey_campaign_targets WHERE campaign_id = $1 - `, [id])) as Array<{ total: number; outstanding: number }>; - if (dto.status === SurveyCampaignStatus.IN_PROGRESS && Number(counts?.total ?? 0) === 0) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_EMPTY', - message: 'Agregá al menos un activo antes de iniciar la campaña', - }); - } - if ( - dto.status === SurveyCampaignStatus.COMPLETED && - Number(counts?.outstanding ?? 0) > 0 - ) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_HAS_OPEN_TARGETS', - message: 'Todos los objetivos deben estar completados u omitidos', - }); - } - const beforeStatus = campaign.status; - campaign.status = dto.status; - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadCampaignView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_CAMPAIGN_STATUS_CHANGED, - entityType: 'survey_campaign', - entityId: id, - beforeData: { status: beforeStatus }, - afterData: { status: dto.status }, - }, manager); - return updated; - }); - } - - async addTarget( - campaignId: string, - dto: AddSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - try { - return await this.dataSource.transaction(async (manager) => { - const campaign = await this.lockCampaign(manager, campaignId); - this.assertCampaignEditable(campaign); - await this.requireAsset(manager, dto.assetId); - await this.assertAssetInScope(manager, dto.assetId, campaign.scopeAssetId); - await this.requireAssignee(manager, dto.assignedUserId ?? null); - const target = manager.getRepository(SurveyCampaignTarget).create({ - campaignId, - assetId: dto.assetId, - assignedUserId: dto.assignedUserId ?? null, - status: SurveyTargetStatus.PENDING, - dueAt: dateValue(dto.dueAt), - instructions: dto.instructions ?? null, - createdBy: principal.userId, - updatedBy: principal.userId, - }); - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const targetView = await this.loadTargetView(manager, target.id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_ADDED, - entityType: 'survey_campaign_target', - entityId: target.id, - afterData: targetView as unknown as Record, - metadata: { campaignId }, - }, manager); - return this.loadCampaignView(manager, campaignId); - }); - } catch (error) { - if (isUniqueViolation(error)) { - throw new ConflictException({ - code: 'SURVEY_TARGET_ALREADY_EXISTS', - message: 'El activo ya forma parte de esta campaña', - }); - } - throw error; - } - } - - async updateTarget( - id: string, - dto: UpdateSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - if (Object.keys(dto).length === 0) { - throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); - } - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - this.assertCampaignEditable(campaign); - this.assertTargetPlanningEditable(target); - const before = await this.loadTargetView(manager, id); - if (dto.dueAt !== undefined) target.dueAt = dateValue(dto.dueAt); - if (dto.instructions !== undefined) target.instructions = dto.instructions; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - const updated = await this.loadTargetView(manager, id); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_UPDATED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: before as unknown as Record, - afterData: updated as unknown as Record, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - async assignTarget( - id: string, - dto: AssignSurveyTargetDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - this.assertCampaignEditable(campaign); - this.assertTargetPlanningEditable(target); - await this.requireAssignee(manager, dto.assignedUserId); - const beforeUserId = target.assignedUserId; - if (beforeUserId === dto.assignedUserId) { - return this.loadCampaignView(manager, target.campaignId); - } - target.assignedUserId = dto.assignedUserId; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_ASSIGNED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: { assignedUserId: beforeUserId }, - afterData: { assignedUserId: dto.assignedUserId }, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - async changeTargetStatus( - id: string, - dto: ChangeSurveyTargetStatusDto, - principal: AuthPrincipal, - request: RequestWithContext, - ): Promise { - return this.dataSource.transaction(async (manager) => { - const target = await this.lockTarget(manager, id); - const campaign = await this.lockCampaign(manager, target.campaignId); - const planningOmission = - principal.permissions.includes('surveys.manage') && - [SurveyCampaignStatus.DRAFT, SurveyCampaignStatus.PLANNED].includes(campaign.status) && - ( - (target.status === SurveyTargetStatus.PENDING && dto.status === SurveyTargetStatus.SKIPPED) || - (target.status === SurveyTargetStatus.SKIPPED && dto.status === SurveyTargetStatus.PENDING) - ); - if (campaign.status !== SurveyCampaignStatus.IN_PROGRESS && !planningOmission) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_NOT_IN_PROGRESS', - message: 'La campaña debe estar en curso para registrar avance; durante la planificación sólo se puede omitir o restaurar un objetivo', - }); - } - 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', - }); - } - if (target.status === dto.status) return this.loadCampaignView(manager, target.campaignId); - if (!planningOmission && !targetTransitions[target.status].includes(dto.status)) { - throw new ConflictException({ - code: 'SURVEY_TARGET_INVALID_TRANSITION', - message: `No se puede pasar el objetivo de ${target.status} a ${dto.status}`, - }); - } - const beforeStatus = target.status; - target.status = dto.status; - target.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaignTarget).save(target); - campaign.updatedBy = principal.userId; - await manager.getRepository(SurveyCampaign).save(campaign); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.SURVEY_TARGET_STATUS_CHANGED, - entityType: 'survey_campaign_target', - entityId: id, - beforeData: { status: beforeStatus }, - afterData: { status: dto.status }, - metadata: { campaignId: target.campaignId }, - }, manager); - return this.loadCampaignView(manager, target.campaignId); - }); - } - - private campaignSelect(where: string): string { - return ` - SELECT - campaign.id, - campaign.code, - campaign.name, - campaign.description, - campaign.status, - campaign.planned_start_at AS "plannedStartAt", - campaign.planned_end_at AS "plannedEndAt", - CASE WHEN scope_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', scope_asset.id, 'code', scope_asset.code, 'name', scope_asset.name - ) END AS "scopeAsset", - CASE WHEN coordinator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( - 'id', coordinator.id, - 'username', coordinator.username, - 'firstName', coordinator.first_name, - 'lastName', coordinator.last_name - ) END AS coordinator, - COALESCE(target_counts.total, 0)::integer AS "targetCount", - COALESCE(target_counts.pending, 0)::integer AS "pendingCount", - COALESCE(target_counts.in_progress, 0)::integer AS "inProgressCount", - COALESCE(target_counts.submitted, 0)::integer AS "submittedCount", - COALESCE(target_counts.completed, 0)::integer AS "completedCount", - COALESCE(target_counts.skipped, 0)::integer AS "skippedCount", - campaign.created_at AS "createdAt", - campaign.updated_at AS "updatedAt" - FROM survey_campaigns campaign - LEFT JOIN assets scope_asset ON scope_asset.id = campaign.scope_asset_id - LEFT JOIN users coordinator ON coordinator.id = campaign.coordinator_user_id - LEFT JOIN LATERAL ( - SELECT - COUNT(*) AS total, - COUNT(*) FILTER (WHERE target.status = 'PENDING') AS pending, - COUNT(*) FILTER (WHERE target.status = 'IN_PROGRESS') AS in_progress, - COUNT(*) FILTER (WHERE target.status = 'SUBMITTED') AS submitted, - COUNT(*) FILTER (WHERE target.status = 'COMPLETED') AS completed, - COUNT(*) FILTER (WHERE target.status = 'SKIPPED') AS skipped - FROM survey_campaign_targets target - WHERE target.campaign_id = campaign.id - ) target_counts ON true - ${where} - `; - } - - private async loadCampaignView( - manager: EntityManager, - id: string, - ): Promise { - const [campaign] = (await manager.query( - this.campaignSelect('WHERE campaign.id = $1'), - [id], - )) as SurveyCampaignListItem[]; - if (!campaign) throw campaignNotFound(); - const targets = (await manager.query(` - ${this.targetSelect()} - WHERE target.campaign_id = $1 - ORDER BY - CASE target.status - WHEN 'SUBMITTED' THEN 1 WHEN 'IN_PROGRESS' THEN 2 WHEN 'PENDING' THEN 3 - WHEN 'COMPLETED' THEN 4 ELSE 5 - END, - target.due_at NULLS LAST, - asset.code - `, [id])) as SurveyTargetView[]; - return { ...campaign, targets }; - } - - private async loadTargetView(manager: EntityManager, id: string): Promise { - const [target] = (await manager.query(` - ${this.targetSelect()} WHERE target.id = $1 - `, [id])) as SurveyTargetView[]; - if (!target) throw targetNotFound(); - return target; - } - - private targetSelect(): string { - return ` - SELECT - target.id, - target.campaign_id AS "campaignId", - JSONB_BUILD_OBJECT( - 'id', asset.id, - 'code', asset.code, - 'name', asset.name, - 'typeName', asset_type.name - ) AS asset, - 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 "assignedUser", - target.status, - target.due_at AS "dueAt", - target.instructions, - target.created_at AS "createdAt", - target.updated_at AS "updatedAt" - FROM survey_campaign_targets target - 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 - `; - } - - private async lockCampaign(manager: EntityManager, id: string): Promise { - const campaign = await manager.getRepository(SurveyCampaign) - .createQueryBuilder('campaign') - .where('campaign.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!campaign) throw campaignNotFound(); - return campaign; - } - - private async lockTarget(manager: EntityManager, id: string): Promise { - const target = await manager.getRepository(SurveyCampaignTarget) - .createQueryBuilder('target') - .where('target.id = :id', { id }) - .setLock('pessimistic_write') - .getOne(); - if (!target) throw targetNotFound(); - return target; - } - - private async requireAsset(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query(`SELECT 1 FROM assets WHERE id = $1`, [id]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_ASSET_NOT_FOUND', - message: 'El activo seleccionado no existe', - }); - } - } - - private async requireActiveUser(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query( - `SELECT 1 FROM users WHERE id = $1 AND status = 'ACTIVE'`, - [id], - ) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_USER_NOT_ACTIVE', - message: 'El usuario seleccionado no existe o está inactivo', - }); - } - } - - private async requireAssignee(manager: EntityManager, id: string | null): Promise { - if (!id) return; - const rows = await manager.query(` - SELECT 1 - FROM users user_account - WHERE user_account.id = $1 - AND user_account.status = 'ACTIVE' - AND EXISTS ( - SELECT 1 - FROM user_roles user_role - INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id - INNER JOIN permissions permission ON permission.id = role_permission.permission_id - WHERE user_role.user_id = user_account.id - AND permission.code = 'surveys.execute' - ) - `, [id]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_ASSIGNEE_INVALID', - message: 'El responsable debe ser un usuario activo con permiso de ejecución', - }); - } - } - - private async assertAssetInScope( - manager: EntityManager, - assetId: string, - scopeAssetId: string | null, - ): Promise { - if (!scopeAssetId) return; - const rows = await manager.query(` - WITH RECURSIVE ancestors AS ( - SELECT id, parent_id FROM assets WHERE id = $1 - UNION ALL - SELECT parent.id, parent.parent_id - FROM assets parent - INNER JOIN ancestors child ON child.parent_id = parent.id - ) - SELECT 1 FROM ancestors WHERE id = $2 - `, [assetId, scopeAssetId]) as unknown[]; - if (rows.length === 0) { - throw new BadRequestException({ - code: 'SURVEY_TARGET_OUTSIDE_SCOPE', - message: 'El activo no pertenece al alcance jerárquico de la campaña', - }); - } - } - - private async assertCampaignTargetsInScope( - manager: EntityManager, - campaignId: string, - scopeAssetId: string | null, - ): Promise { - if (!scopeAssetId) return; - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS outside - FROM survey_campaign_targets target - WHERE target.campaign_id = $1 - AND NOT EXISTS ( - WITH RECURSIVE ancestors AS ( - SELECT id, parent_id FROM assets WHERE id = target.asset_id - UNION ALL - SELECT parent.id, parent.parent_id - FROM assets parent - INNER JOIN ancestors child ON child.parent_id = parent.id - ) - SELECT 1 FROM ancestors WHERE id = $2 - ) - `, [campaignId, scopeAssetId])) as Array<{ outside: number }>; - if (Number(row?.outside ?? 0) > 0) { - throw new ConflictException({ - code: 'SURVEY_SCOPE_EXCLUDES_TARGETS', - message: 'El nuevo alcance dejaría objetivos existentes fuera de la campaña', - }); - } - } - - private async validateDates( - start: string | Date | null, - end: string | Date | null, - ): Promise { - if (start && end && new Date(end).getTime() < new Date(start).getTime()) { - throw new BadRequestException({ - code: 'SURVEY_INVALID_DATES', - message: 'La fecha de finalización no puede ser anterior al inicio', - }); - } - } - - private assertCampaignEditable(campaign: SurveyCampaign): void { - if ( - campaign.status === SurveyCampaignStatus.COMPLETED || - campaign.status === SurveyCampaignStatus.CANCELLED - ) { - throw new ConflictException({ - code: 'SURVEY_CAMPAIGN_CLOSED', - message: 'Una campaña completada o cancelada ya no puede modificarse', - }); - } - } - - private assertTargetPlanningEditable(target: SurveyCampaignTarget): void { - if ( - target.status === SurveyTargetStatus.SUBMITTED || - target.status === SurveyTargetStatus.COMPLETED - ) { - throw new ConflictException({ - code: 'SURVEY_TARGET_PLANNING_LOCKED', - message: 'Un objetivo enviado o completado ya no puede replanificarse', - }); - } - } - - private campaignConflict(): ConflictException { - return new ConflictException({ - code: 'SURVEY_CAMPAIGN_CODE_EXISTS', - message: 'Ya existe una campaña con ese código', - }); - } - - private auditCampaign(campaign: SurveyCampaignView): Record { - const { targets: _targets, ...summary } = campaign; - return summary as unknown as Record; - } -}