Files
HoruxDespachos/apps/api/src/controllers/tareas.controller.ts
2026-04-27 01:11:06 -06:00

178 lines
6.7 KiB
TypeScript

import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as tareasService from '../services/tareas.service.js';
import { emailService } from '../services/email/email.service.js';
import { getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
/**
* Bloquea a usuarios rol `cliente` de cualquier endpoint de tareas.
* El cliente no debe ver tareas operativas internas del despacho.
*/
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Tareas no disponibles para usuarios cliente');
}
}
const RECURRENCIAS = ['semanal', 'quincenal', 'mensual', 'bimestral', 'trimestral', 'semestral', 'anual'] as const;
const tareaSchema = z.object({
nombre: z.string().min(1).max(200),
descripcion: z.string().max(1000).nullable().optional(),
recurrencia: z.enum(RECURRENCIAS),
diaSemana: z.number().int().min(1).max(7).nullable().optional(),
diaMes: z.number().int().min(1).max(31).nullable().optional(),
soloSupervisorCompleta: z.boolean().optional(),
orden: z.number().int().optional(),
});
export async function listTareas(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const tareas = await tareasService.listTareasConPeriodoActual(req.tenantPool!, contribuyenteId);
res.json(tareas);
} catch (error) {
next(error);
}
}
export async function createTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const data = tareaSchema.parse(req.body);
const tarea = await tareasService.createTarea(req.tenantPool!, contribuyenteId, data);
res.status(201).json(tarea);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function updateTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const data = tareaSchema.partial().parse(req.body);
const updated = await tareasService.updateTarea(req.tenantPool!, String(req.params.id), data);
if (!updated) return next(new AppError(404, 'Tarea no encontrada'));
res.json(updated);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function deleteTarea(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const ok = await tareasService.deleteTarea(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Tarea no encontrada'));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function completarPeriodo(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const { notas } = z.object({ notas: z.string().max(1000).nullable().optional() }).parse(req.body);
const result = await tareasService.completarPeriodo(
req.tenantPool!,
String(req.params.id),
req.user!.userId,
req.user!.role,
notas ?? null,
);
if (!result) return next(new AppError(404, 'Periodo no encontrado'));
// Notificar al auxiliar de la cartera SOLO cuando una tarea con
// solo_supervisor_completa=true fue marcada como completada por
// un supervisor/owner. Fire-and-forget — no bloquea la respuesta.
if (result.tarea.soloSupervisorCompleta) {
notifyAuxiliarTareaCompletada(req, result).catch(err =>
console.error('[tareas.completar] notify auxiliar failed:', err?.message || err),
);
}
res.json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Solo supervisor')) return next(new AppError(403, error.message));
next(error);
}
}
async function notifyAuxiliarTareaCompletada(
req: Request,
result: { periodo: tareasService.TareaPeriodo; tarea: tareasService.TareaCatalogo },
): Promise<void> {
const auxiliarUserId = await tareasService.getAuxiliarUserIdDeContribuyente(
req.tenantPool!,
result.tarea.contribuyenteId,
);
if (!auxiliarUserId) return;
if (auxiliarUserId === req.user!.userId) return; // no notificarse a sí mismo
const auxiliarEmail = await getUserEmailById(auxiliarUserId);
if (!auxiliarEmail) return;
// Datos del contribuyente y supervisor para el email
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[result.tarea.contribuyenteId],
);
if (rows.length === 0) return;
const auxiliarNombre = (await prisma.user.findUnique({
where: { id: auxiliarUserId },
select: { nombre: true },
}))?.nombre || 'Auxiliar';
const fechaLimite = result.periodo.fechaLimite instanceof Date
? result.periodo.fechaLimite.toLocaleDateString('es-MX', { dateStyle: 'long' })
: new Date(String(result.periodo.fechaLimite)).toLocaleDateString('es-MX', { dateStyle: 'long' });
await emailService.sendTareaCompletada(auxiliarEmail, {
destinatarioNombre: auxiliarNombre,
contribuyenteNombre: rows[0].nombre,
contribuyenteRfc: rows[0].rfc,
tareaNombre: result.tarea.nombre,
tareaDescripcion: result.tarea.descripcion,
completadaPor: req.user!.email,
notas: result.periodo.notas,
fechaLimite,
link: `${env.FRONTEND_URL}/configuracion/obligaciones`,
});
}
export async function descompletarPeriodo(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const ok = await tareasService.descompletarPeriodo(req.tenantPool!, String(req.params.id));
if (!ok) return next(new AppError(404, 'Periodo no encontrado'));
res.status(204).send();
} catch (error) {
next(error);
}
}
export async function seedDefaults(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const created = await tareasService.seedTareasDefault(req.tenantPool!, contribuyenteId);
res.json({ created });
} catch (error) {
next(error);
}
}