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