feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.) - Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos - Backend: list/download permiten acceso a clientes filtrando por entidades visibles - Backend: notificación por email a clientes cuando se les solicita aprobación - Frontend: checkbox independiente para solicitar aprobación del cliente - Frontend: badge de estado combinado (owner + cliente) - Frontend: botones de aprobar/rechazar para clientes en su propio flujo
This commit is contained in:
@@ -4,15 +4,10 @@ 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 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;
|
||||
}
|
||||
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
|
||||
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),
|
||||
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
|
||||
|
||||
export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
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');
|
||||
|
||||
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
|
||||
anio: data.anio,
|
||||
mes: data.mes,
|
||||
requiereAprobacion: data.requiereAprobacion,
|
||||
requiereAprobacionCliente: data.requiereAprobacionCliente,
|
||||
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),
|
||||
);
|
||||
}
|
||||
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) {
|
||||
@@ -74,13 +77,20 @@ const listSchema = z.object({
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
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) {
|
||||
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
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 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);
|
||||
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function aprobar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
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(
|
||||
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
|
||||
|
||||
export async function rechazar(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
rejectClienteRole(req);
|
||||
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);
|
||||
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
rejectClienteRole(req);
|
||||
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);
|
||||
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
// ─── 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.
|
||||
* 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 } } },
|
||||
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
|
||||
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 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'];
|
||||
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await emailService.sendPapeleriaAprobacionRequerida(to, {
|
||||
contribuyenteRfc: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
descripcion: item.descripcion,
|
||||
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
|
||||
*/
|
||||
async function notifyDecisionAuxiliar(
|
||||
req: Request,
|
||||
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
|
||||
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 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: rows[0].rfc,
|
||||
contribuyenteNombre: rows[0].nombre,
|
||||
contribuyenteRfc: info.rfc,
|
||||
contribuyenteNombre: info.nombre,
|
||||
nombreDocumento: item.nombre,
|
||||
estado: item.estado as 'aprobado' | 'rechazado',
|
||||
revisor: req.user!.email,
|
||||
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user