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 { getEntidadesVisibles } from '../utils/entidades-visibles.js'; import { env } from '../config/env.js'; import { prisma } from '../config/database.js'; 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(), requiereAprobacionCliente: 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 { if (req.user?.role === 'cliente') { throw new AppError(403, 'Los clientes no pueden subir documentos de papelería'); } 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, requiereAprobacionCliente: data.requiereAprobacionCliente, archivo, archivoFilename: data.archivoFilename, archivoMime: data.archivoMime, subidoPor: req.user!.userId, }); if (item.requiereAprobacion) { notifyAprobacionRequerida(req, item).catch(err => console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err), ); } if (item.requiereAprobacionCliente) { notifyClienteAprobacionRequerida(req, item).catch(err => console.error('[papeleria.upload] notify clientes 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 { const q = listSchema.parse(req.query); const entidadIds = await getEntidadesVisibles( req.tenantPool!, req.user!.userId, req.user!.role, ); if (!entidadIds.includes(q.contribuyenteId)) { return res.json([]); } 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, entidadIds, userRole: req.user!.role, }); 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 { const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); const item = await papeleriaService.getById(req.tenantPool!, id); if (!item) return next(new AppError(404, 'Documento no encontrado')); const entidadIds = await getEntidadesVisibles( req.tenantPool!, req.user!.userId, req.user!.role, ); if (!entidadIds.includes(item.contribuyenteId)) { return next(new AppError(403, 'No tienes acceso a este documento')); } if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) { return next(new AppError(403, 'No tienes acceso a este documento')); } 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 { if (req.user?.role === 'cliente') { throw new AppError(403, 'Los clientes no pueden usar este endpoint'); } 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 { if (req.user?.role === 'cliente') { throw new AppError(403, 'Los clientes no pueden usar este endpoint'); } 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 aprobarCliente(req: Request, res: Response, next: NextFunction) { try { if (req.user?.role !== 'cliente') { throw new AppError(403, 'Solo clientes pueden usar este endpoint'); } const id = parseInt(String(req.params.id), 10); if (isNaN(id)) return next(new AppError(400, 'ID inválido')); const entidadIds = await getEntidadesVisibles( req.tenantPool!, req.user!.userId, req.user!.role, ); const itemCheck = await papeleriaService.getById(req.tenantPool!, id); if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) { return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); } const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId); if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); res.json(item); } catch (error) { next(error); } } export async function rechazarCliente(req: Request, res: Response, next: NextFunction) { try { if (req.user?.role !== 'cliente') { throw new AppError(403, 'Solo clientes pueden usar este endpoint'); } 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 entidadIds = await getEntidadesVisibles( req.tenantPool!, req.user!.userId, req.user!.role, ); const itemCheck = await papeleriaService.getById(req.tenantPool!, id); if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) { return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); } const item = await papeleriaService.rechazarCliente( req.tenantPool!, id, req.user!.userId, comentario ?? null, ); if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación')); res.json(item); } catch (error: any) { if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); next(error); } } export async function eliminar(req: Request, res: Response, next: NextFunction) { try { if (req.user?.role === 'cliente') { throw new AppError(403, 'Los clientes no pueden eliminar documentos'); } 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 ─── async function getContribuyenteInfo(req: Request, contribuyenteId: string) { 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`, [contribuyenteId], ); return rows[0] ?? null; } /** * Notifica a owners y supervisores cuando una papelería requiere aprobación. */ async function notifyAprobacionRequerida( req: Request, item: papeleriaService.PapeleriaItem, ): Promise { const tenantId = effectiveTenantId(req); const recipients = new Set(await getTenantOwnerEmails(tenantId)); 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); } recipients.delete(req.user!.email); if (recipients.size === 0) return; const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true }, }); const info = await getContribuyenteInfo(req, item.contribuyenteId); if (!info) 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: info.rfc, contribuyenteNombre: info.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 cuando un documento fue aprobado o rechazado por owner/supervisor. */ async function notifyDecisionAuxiliar( req: Request, item: papeleriaService.PapeleriaItem, ): Promise { if (item.subidoPor === req.user!.userId) return; const auxiliarEmail = await getUserEmailById(item.subidoPor); if (!auxiliarEmail) return; const info = await getContribuyenteInfo(req, item.contribuyenteId); if (!info) 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: info.rfc, contribuyenteNombre: info.nombre, nombreDocumento: item.nombre, estado: item.estado as 'aprobado' | 'rechazado', revisor: req.user!.email, comentario: item.comentarioRechazo, periodo, link, }); } /** * Notifica a los usuarios cliente asociados al contribuyente cuando un documento * requiere su aprobación. */ async function notifyClienteAprobacionRequerida( req: Request, item: papeleriaService.PapeleriaItem, ): Promise { const tenantId = effectiveTenantId(req); // Obtener user_ids de clientes con acceso a este contribuyente const { rows } = await req.tenantPool!.query<{ user_id: string }>( `SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`, [item.contribuyenteId], ); if (rows.length === 0) return; const userIds = rows.map(r => r.user_id); const users = await prisma.user.findMany({ where: { id: { in: userIds }, active: true }, select: { email: true }, }); const recipients = users.map(u => u.email).filter(Boolean) as string[]; if (recipients.length === 0) return; const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true }, }); const info = await getContribuyenteInfo(req, item.contribuyenteId); if (!info) 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.sendPapeleriaAprobacionClienteRequerida(to, { contribuyenteRfc: info.rfc, contribuyenteNombre: info.nombre, despachoNombre: tenant?.nombre, nombreDocumento: item.nombre, descripcion: item.descripcion, periodo, subidoPor: req.user!.email, link, }); } catch (err: any) { console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err); } } }