F4: remove obsolete survey planning service

This commit is contained in:
2026-09-07 23:15:19 -03:00
parent 17e2e1902b
commit 0bab3e5b70
@@ -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>;
}
}