178 lines
6.7 KiB
TypeScript
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);
|
|
}
|
|
}
|