Initial commit: Horux Despachos project
This commit is contained in:
177
apps/api/src/controllers/tareas.controller.ts
Normal file
177
apps/api/src/controllers/tareas.controller.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user