F4.6 · eliminar código muerto de Campañas
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AssignSurveyTargetDto {
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
assignedUserId!: string | null;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SurveyCampaignStatus } from '../../database/entities';
|
||||
|
||||
export class ChangeSurveyCampaignStatusDto {
|
||||
@IsEnum(SurveyCampaignStatus)
|
||||
status!: SurveyCampaignStatus;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SurveyTargetStatus } from '../../database/entities';
|
||||
|
||||
export class ChangeSurveyTargetStatusDto {
|
||||
@IsEnum(SurveyTargetStatus)
|
||||
status!: SurveyTargetStatus;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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, SurveyCampaignStatus[]> = {
|
||||
[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, SurveyTargetStatus[]> = {
|
||||
[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<SurveyCampaignView> {
|
||||
return this.dataSource.transaction((manager) => this.loadCampaignView(manager, id));
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateSurveyCampaignDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
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<SurveyCampaignView> {
|
||||
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<SurveyCampaignView> {
|
||||
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<SurveyCampaignView> {
|
||||
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<string, unknown>,
|
||||
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<SurveyCampaignView> {
|
||||
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<string, unknown>,
|
||||
afterData: updated as unknown as Record<string, unknown>,
|
||||
metadata: { campaignId: target.campaignId },
|
||||
}, manager);
|
||||
return this.loadCampaignView(manager, target.campaignId);
|
||||
});
|
||||
}
|
||||
|
||||
async assignTarget(
|
||||
id: string,
|
||||
dto: AssignSurveyTargetDto,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
): Promise<SurveyCampaignView> {
|
||||
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<SurveyCampaignView> {
|
||||
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<SurveyCampaignView> {
|
||||
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<SurveyTargetView> {
|
||||
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<SurveyCampaign> {
|
||||
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<SurveyCampaignTarget> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> {
|
||||
const { targets: _targets, ...summary } = campaign;
|
||||
return summary as unknown as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user