Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
import type { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js';
import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId;
}
const uploadSchema = z.object({
contribuyenteId: z.string().uuid(),
nombre: z.string().min(1).max(255),
descripcion: z.string().max(2000).nullable().optional(),
anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(),
archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100),
});
export async function upload(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64');
const item = await papeleriaService.uploadPapeleria(req.tenantPool!, {
contribuyenteId: data.contribuyenteId,
nombre: data.nombre,
descripcion: data.descripcion ?? null,
anio: data.anio,
mes: data.mes,
requiereAprobacion: data.requiereAprobacion,
archivo,
archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime,
subidoPor: req.user!.userId,
});
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
);
}
res.status(201).json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Formato no permitido') || error?.message?.startsWith('Archivo excede')) {
return next(new AppError(400, error.message));
}
next(error);
}
}
const listSchema = z.object({
contribuyenteId: z.string().uuid(),
anio: z.string().regex(/^\d{4}$/).optional(),
mes: z.string().regex(/^\d{1,2}$/).optional(),
estado: z.enum(['pendiente', 'aprobado', 'rechazado', 'sin_aprobacion']).optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const q = listSchema.parse(req.query);
const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado,
});
res.json(items);
} catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function download(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime);
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.filename)}"`);
res.send(file.archivo);
} catch (error) {
next(error);
}
}
export async function aprobar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar(
req.tenantPool!, id, req.user!.userId, req.user!.role,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
notifyDecisionAuxiliar(req, item).catch(err =>
console.error('[papeleria.aprobar] notify auxiliar failed:', err?.message || err),
);
res.json(item);
} catch (error: any) {
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
next(error);
}
}
const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().optional() });
export async function rechazar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const item = await papeleriaService.rechazar(
req.tenantPool!, id, req.user!.userId, req.user!.role, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere aprobación'));
notifyDecisionAuxiliar(req, item).catch(err =>
console.error('[papeleria.rechazar] notify auxiliar failed:', err?.message || err),
);
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
if (error?.message?.startsWith('Solo owner')) return next(new AppError(403, error.message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) {
try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id);
if (!ok) return next(new AppError(404, 'Documento no encontrado'));
res.status(204).send();
} catch (error) {
next(error);
}
}
// ─── Notificaciones ───
/**
* Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/
async function notifyAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } },
});
for (const m of supervisores) {
if (m.user.active && m.user.email) recipients.add(m.user.email);
}
// No notificarse a sí mismo
recipients.delete(req.user!.email);
if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
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`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion a ${to}:`, err?.message || err);
}
}
}
/**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
*/
async function notifyDecisionAuxiliar(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
if (item.subidoPor === req.user!.userId) return;
const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return;
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`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc,
contribuyenteNombre: rows[0].nombre,
nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email,
comentario: item.comentarioRechazo,
periodo,
link,
});
}