F4: remove obsolete survey planning service
This commit is contained in:
@@ -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