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; - } -}