feat(notificaciones): configuración de notificaciones por rol

- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
This commit is contained in:
Horux Dev
2026-06-17 00:04:37 +00:00
parent 8a1fbceb38
commit b217342a96
8 changed files with 380 additions and 192 deletions

View File

@@ -3,29 +3,42 @@ import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { import {
EMAIL_TYPES, EMAIL_TYPES,
getEmailPreferencesPorContribuyente, NOTIFICATION_ROLES,
setContribuyenteEmailPreferences, getRoleEmailPreferences,
setRoleEmailPreference,
type EmailType,
type NotificationRole,
} from '../services/notification-preferences.service.js'; } from '../services/notification-preferences.service.js';
export async function listPreferences(req: Request, res: Response, next: NextFunction) { export async function listPreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!); const preferences = await getRoleEmailPreferences(req.tenantPool!);
res.json({ emailTypes: EMAIL_TYPES, data }); res.json({
emailTypes: EMAIL_TYPES,
roles: NOTIFICATION_ROLES,
preferences,
});
} catch (error) { } catch (error) {
next(error); next(error);
} }
} }
const updateSchema = z.object({ const updateSchema = z.object({
contribuyenteId: z.string().uuid(), emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
preferences: z.record(z.string(), z.boolean()), role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
enabled: z.boolean(),
}); });
export async function updatePreferences(req: Request, res: Response, next: NextFunction) { export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const { contribuyenteId, preferences } = updateSchema.parse(req.body); const { emailType, role, enabled } = updateSchema.parse(req.body);
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences); const preferences = await setRoleEmailPreference(
res.json({ contribuyenteId, preferences: updated }); req.tenantPool!,
emailType as EmailType,
role as NotificationRole,
enabled,
);
res.json({ preferences });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error); next(error);

View File

@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
import { getKpis } from '../services/dashboard.service.js'; import { getKpis } from '../services/dashboard.service.js';
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js'; import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
return { sent: 0 }; return { sent: 0 };
} }
// Recipientes: owners activos del tenant // Pool del tenant para queries de preferencias y CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
const owners = await prisma.tenantMembership.findMany({ const owners = await prisma.tenantMembership.findMany({
where: { tenantId, isOwner: true, active: true }, where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true, nombre: true, active: true } } }, include: { user: { select: { email: true, nombre: true, active: true } } },
}); });
const recipients = owners.filter(o => o.user.active); const activeOwners = owners.filter(o => o.user.active);
if (recipients.length === 0) { if (activeOwners.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`); console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
return { sent: 0 }; return { sent: 0 };
} }
// Pool del tenant para queries de CFDI const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
if (recipients.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
return { sent: 0 };
}
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange(); const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS notification_role_preferences (
id SERIAL PRIMARY KEY,
email_type VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (email_type, role)
);
INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES
('documento_subido','owner',true),
('documento_subido','supervisor',true),
('documento_subido','auxiliar',true),
('documento_subido','cliente',true),
('weekly_update','owner',true),
('weekly_update','supervisor',true),
('weekly_update','auxiliar',true),
('weekly_update','cliente',true),
('subscription_expiring','owner',true),
('subscription_expiring','supervisor',true),
('subscription_expiring','auxiliar',true),
('subscription_expiring','cliente',true),
('recordatorio_fiscal','owner',true),
('recordatorio_fiscal','supervisor',true),
('recordatorio_fiscal','auxiliar',true),
('recordatorio_fiscal','cliente',true),
('alertas_nuevas','owner',true),
('alertas_nuevas','supervisor',true),
('alertas_nuevas','auxiliar',true),
('alertas_nuevas','cliente',true),
('recordatorio_proximo','owner',true),
('recordatorio_proximo','supervisor',true),
('recordatorio_proximo','auxiliar',true),
('recordatorio_proximo','cliente',true)
ON CONFLICT (email_type, role) DO NOTHING;

View File

@@ -1,30 +1,49 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
/** /**
* Tipos de correos informativos cuyo envío puede desactivarse por * Tipos de correos informativos cuyo envío puede desactivarse por rol.
* contribuyente. NO incluye correos transaccionales críticos * NO incluye correos transaccionales críticos (welcome, password-reset,
* (welcome, password-reset, payment-*) — esos siempre se envían. * payment-*, invitaciones) — esos siempre se envían.
* *
* Estado de implementación: * Estado de implementación:
* - documento_subido: ✅ implementado y configurable por contribuyente * - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
* - weekly_update: ✅ implementado (job tenant-wide, no configurable) * - weekly_update: ✅ implementado (job tenant-wide, owners)
* - subscription_expiring: ✅ implementado (aviso a owner, no configurable) * - subscription_expiring: ✅ implementado (aviso a owner)
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas * - recordatorio_fiscal: ⏳ placeholder para futuras alertas
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
*/ */
export const EMAIL_TYPES = [ export const EMAIL_TYPES = [
'documento_subido', 'documento_subido',
'weekly_update', 'weekly_update',
'subscription_expiring', 'subscription_expiring',
'recordatorio_fiscal', 'recordatorio_fiscal',
'alertas_nuevas',
'recordatorio_proximo',
] as const; ] as const;
export type EmailType = (typeof EMAIL_TYPES)[number]; export type EmailType = (typeof EMAIL_TYPES)[number];
/**
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
*/
export const NOTIFICATION_ROLES = [
'owner',
'supervisor',
'auxiliar',
'cliente',
] as const;
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
export type EmailPreferences = Record<EmailType, boolean>; export type EmailPreferences = Record<EmailType, boolean>;
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
/** /**
* Default: todo activado. Si el JSONB en BD viene vacío o falta una * Default legacy (por contribuyente). Se mantiene por compatibilidad con la
* key, asumimos `true` para preservar el comportamiento previo. * columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
*/ */
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences { function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
const out = {} as EmailPreferences; const out = {} as EmailPreferences;
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, ''); return id.replace(/[^a-f0-9-]/gi, '');
} }
/** // ═══════════════════════════════════════════════════════════════════════════
* Lee las preferencias de un contribuyente. Devuelve defaults (todo // Preferencias por contribuyente (legacy — conservado por compatibilidad)
* activado) si no hay fila o la columna está vacía. // ═══════════════════════════════════════════════════════════════════════════
*/
export async function getContribuyenteEmailPreferences( export async function getContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
return applyDefaults(raw); return applyDefaults(raw);
} }
/**
* Actualiza las preferencias de un contribuyente. Solo persiste las
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
* existente (no sobreescribe keys no enviadas).
*/
export async function setContribuyenteEmailPreferences( export async function setContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
return getContribuyenteEmailPreferences(pool, contribuyenteId); return getContribuyenteEmailPreferences(pool, contribuyenteId);
} }
/**
* Lee preferencias para múltiples contribuyentes en una sola query.
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
*/
export async function getEmailPreferencesPorContribuyente( export async function getEmailPreferencesPorContribuyente(
pool: Pool, pool: Pool,
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> { ): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
preferences: applyDefaults(r.email_preferences ?? {}), preferences: applyDefaults(r.email_preferences ?? {}),
})); }));
} }
// ═══════════════════════════════════════════════════════════════════════════
// Preferencias por rol (nuevo modelo)
// ═══════════════════════════════════════════════════════════════════════════
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
const out = {} as RoleEmailPreferences;
for (const t of EMAIL_TYPES) {
out[t] = {} as Record<NotificationRole, boolean>;
for (const r of NOTIFICATION_ROLES) {
const row = raw.find(x => x.email_type === t && x.role === r);
out[t][r] = row ? row.enabled : true;
}
}
return out;
}
/**
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
* un (type, role), asume `true` para no romper el comportamiento previo.
*/
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
`SELECT email_type, role, enabled FROM notification_role_preferences`
);
return applyRoleDefaults(rows);
}
/**
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
*/
export async function setRoleEmailPreference(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
enabled: boolean,
): Promise<RoleEmailPreferences> {
await pool.query(
`INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES ($1, $2, $3)
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
[emailType, role, enabled],
);
return getRoleEmailPreferences(pool);
}
/**
* Devuelve true si el rol tiene habilitado el tipo de notificación.
* Fallback a true si no hay fila (comportamiento seguro).
*/
export async function isRoleEnabled(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
): Promise<boolean> {
const { rows } = await pool.query<{ enabled: boolean }>(
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
[emailType, role],
);
return rows[0]?.enabled ?? true;
}
interface RecipientWithRole {
email: string;
role: NotificationRole;
}
/**
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
* Si no hay preferencias para un (type, role), se conserva el destinatario.
*/
export async function filterRecipientsByRole(
pool: Pool,
emailType: EmailType,
recipients: RecipientWithRole[],
): Promise<string[]> {
const prefs = await getRoleEmailPreferences(pool);
const typePrefs = prefs[emailType];
const filtered = recipients.filter(r => {
if (!typePrefs) return true;
return typePrefs[r.role] !== false;
});
return [...new Set(filtered.map(r => r.email))];
}
export type { RecipientWithRole };

View File

@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import type { AlertaItem } from './email/templates/alertas-nuevas.js'; import type { AlertaItem } from './email/templates/alertas-nuevas.js';
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js'; import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
import {
filterRecipientsByRole,
type RecipientWithRole,
type EmailType,
type NotificationRole,
} from './notification-preferences.service.js';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
/** /**
* Destinatarios de una alerta: supervisor + auxiliares + clientes del * Destinatarios de una alerta: supervisor + auxiliares + clientes del
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido * contribuyente. Retorna emails con su rol para poder filtrar por
* (no se duplica). * preferencias de notificación.
*/ */
async function recipientsForAlerta( async function recipientsForAlerta(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId: string, contribuyenteId: string,
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
const ids = await getUserIdsContribuyente(pool, contribuyenteId); const ids = await getUserIdsContribuyente(pool, contribuyenteId);
const userIds = new Set<string>(); const byRole = new Map<string, NotificationRole>();
if (ids.supervisor) userIds.add(ids.supervisor); if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
ids.auxiliares.forEach(id => userIds.add(id)); ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
ids.clientes.forEach(id => userIds.add(id)); ids.clientes.forEach(id => byRole.set(id, 'cliente'));
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))]; const contacts = await getUserContacts([...byRole.keys()]);
return contacts
.filter(c => byRole.has(c.userId))
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
}
async function getUserRole(
tenantId: string,
userId: string,
): Promise<NotificationRole | null> {
const m = await prisma.tenantMembership.findFirst({
where: { userId, tenantId, active: true },
include: { rol: { select: { nombre: true } } },
});
if (!m) return null;
const role = m.rol.nombre;
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
return role;
}
return null;
} }
/** /**
* Destinatarios de un recordatorio. Los recordatorios del despacho son * Destinatarios de un recordatorio. Los recordatorios del despacho son
* tenant-level (no atados a contribuyente). Para públicos: clientes con * tenant-level (no atados a contribuyente). Retorna emails con rol para
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares, * filtrado por preferencias.
* supervisores; si owner aparece como supervisor, también recibe.
* *
* Públicos: clientes + auxiliares + supervisores + owners.
* Privados: solo el creador. * Privados: solo el creador.
*/ */
async function recipientsForRecordatorio( async function recipientsForRecordatorio(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
recordatorio: { creadoPor: string; privado: boolean }, recordatorio: { creadoPor: string; privado: boolean },
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
if (recordatorio.privado) { if (recordatorio.privado) {
const role = await getUserRole(tenantId, recordatorio.creadoPor);
if (!role) return [];
const contacts = await getUserContacts([recordatorio.creadoPor]); const contacts = await getUserContacts([recordatorio.creadoPor]);
return [...new Set(contacts.map(c => c.email))]; return contacts.map(c => ({ email: c.email, role }));
} }
// Recordatorio público: lee universos relevantes del tenant. // Recordatorio público: lee universos relevantes del tenant.
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
), ARRAY[]::uuid[]) AS cliente_user_ids ), ARRAY[]::uuid[]) AS cliente_user_ids
`); `);
const auxiliares = r?.auxiliar_user_ids ?? []; const byRole = new Map<string, NotificationRole>();
const supervisores = r?.supervisor_user_ids ?? []; (r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
const clientes = r?.cliente_user_ids ?? []; (r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
const owners = await getOwnerUserIds(tenantId); const owners = await getOwnerUserIds(tenantId);
owners.forEach(id => byRole.set(id, 'owner'));
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares, const contacts = await getUserContacts([...byRole.keys()]);
// agregar supervisores. Si owner es supervisor y no hay auxiliares, return contacts
// owner queda incluido vía la lista de supervisores. .filter(c => byRole.has(c.userId))
const userIds = new Set<string>(); .map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
clientes.forEach(id => userIds.add(id));
auxiliares.forEach(id => userIds.add(id));
if (auxiliares.length === 0) {
supervisores.forEach(id => userIds.add(id));
// Solo si owner aparece como supervisor (intersección):
for (const ownerId of owners) {
if (supervisores.includes(ownerId)) userIds.add(ownerId);
}
}
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))];
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
return { nuevas: 0, resueltas }; return { nuevas: 0, resueltas };
} }
// Envía email batched a los responsables del contribuyente. // Envía email batched a los responsables del contribuyente, filtrando por
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId); // preferencias de rol para alertas_nuevas.
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`); console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
return { nuevas: nuevas.length, resueltas }; return { nuevas: nuevas.length, resueltas };
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
for (const r of rows) { for (const r of rows) {
try { try {
const recipients = await recipientsForRecordatorio(pool, tenantId, { const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
creadoPor: r.creado_por, creadoPor: r.creado_por,
privado: r.privado, privado: r.privado,
}); });
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`); console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
continue; continue;

View File

@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js'; import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
import type { DocumentoSubidoData } from './email/templates/documento-subido.js'; import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
/** /**
@@ -34,10 +34,7 @@ export async function notifyDocumentoSubido(params: {
// subject informativo ni supervisor — skip. // subject informativo ni supervisor — skip.
if (!contribuyenteId) return; if (!contribuyenteId) return;
// Respeta preferencias de notificación del contribuyente. Si el user
// desactivó `documento_subido` para este contribuyente, no enviar.
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
if (!prefs.documento_subido) return;
const { rows } = await pool.query<{ const { rows } = await pool.query<{
rfc: string; rfc: string;
@@ -54,14 +51,17 @@ export async function notifyDocumentoSubido(params: {
const contrib = rows[0]; const contrib = rows[0];
// 2. Recipients. Owners primero; luego supervisor si aplica. // 2. Recipients. Owners primero; luego supervisor si aplica.
const owners = await getTenantOwnerEmails(tenantId); const ownerEmails = await getTenantOwnerEmails(tenantId);
const recipients = new Set<string>(owners); const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
if (contrib.supervisor_user_id) { if (contrib.supervisor_user_id) {
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id); const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
if (supervisorEmail) recipients.add(supervisorEmail); if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
} }
// Filtra por preferencias de rol para documento_subido.
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
// Excluir al uploader: no notificarle su propia acción. // Excluir al uploader: no notificarle su propia acción.
recipients.delete(subidoPor.toLowerCase()); recipients.delete(subidoPor.toLowerCase());
recipients.delete(subidoPor); recipients.delete(subidoPor);

View File

@@ -1,8 +1,9 @@
import { prisma } from '../../config/database.js'; import { prisma, tenantDb } from '../../config/database.js';
import * as mpService from './mercadopago.service.js'; import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js'; import { emailService } from '../email/email.service.js';
import { auditLog } from '../../utils/audit.js'; import { auditLog } from '../../utils/audit.js';
import { getTenantOwnerEmail } from '../../utils/memberships.js'; import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
import { filterRecipientsByRole } from '../notification-preferences.service.js';
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared'; import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js'; import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
import { import {
@@ -1191,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } }, { status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
], ],
}, },
include: { tenant: { select: { nombre: true, rfc: true } } }, include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
}); });
let sent = 0; let sent = 0;
@@ -1235,13 +1236,27 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
// Hay algo que avisar. // Hay algo que avisar.
try { try {
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
// Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
let emailsToNotify: string[] = [];
if (isTrialFlow) {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId); const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
if (!ownerEmail) { if (ownerEmail) emailsToNotify = [ownerEmail];
} else {
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
}
if (emailsToNotify.length === 0) {
skipped++; skipped++;
continue; continue;
} }
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired'; for (const ownerEmail of emailsToNotify) {
if (isTrialFlow) { if (isTrialFlow) {
if (bucket === 0) { if (bucket === 0) {
await emailService.sendTrialExpired(ownerEmail, { await emailService.sendTrialExpired(ownerEmail, {
@@ -1263,6 +1278,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }), expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
}); });
} }
}
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: sub.id }, where: { id: sub.id },

View File

@@ -1,55 +1,59 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header'; import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Bell, Loader2 } from 'lucide-react'; import { Bell, Loader2 } from 'lucide-react';
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending'; configurable: boolean }> = { const ROLE_LABELS: Record<string, string> = {
owner: 'Owner',
supervisor: 'Supervisor',
auxiliar: 'Auxiliar',
cliente: 'Cliente',
};
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
documento_subido: { documento_subido: {
label: 'Documento subido', label: 'Documento subido',
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.', description: 'Cuando se sube una declaración o documento extra del contribuyente.',
status: 'active', status: 'active',
configurable: true,
}, },
weekly_update: { weekly_update: {
label: 'Reporte semanal', label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.', description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'active', status: 'active',
configurable: false,
}, },
subscription_expiring: { subscription_expiring: {
label: 'Vencimiento de suscripción', label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.', description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'active', status: 'active',
configurable: false,
}, },
recordatorio_fiscal: { recordatorio_fiscal: {
label: 'Recordatorios fiscales', label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).', description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending', status: 'pending',
configurable: false, },
alertas_nuevas: {
label: 'Alertas nuevas',
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
status: 'active',
},
recordatorio_proximo: {
label: 'Recordatorios próximos',
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
status: 'active',
}, },
}; };
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse { interface ListResponse {
emailTypes: string[]; emailTypes: string[];
data: ContribuyentePrefs[]; roles: string[];
preferences: Record<string, Record<string, boolean>>;
} }
export default function NotificacionesPage() { export default function NotificacionesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({ const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'], queryKey: ['notification-preferences'],
@@ -59,32 +63,23 @@ export default function NotificacionesPage() {
}, },
}); });
// Aplica el filtro del selector global de contribuyente. Si hay uno
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
const visibles = useMemo(() => {
if (!data) return [];
if (!selectedContribuyenteId) return data.data;
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
}, [data, selectedContribuyenteId]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => { mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', { await apiClient.put('/notificaciones', { emailType, role, enabled });
contribuyenteId,
preferences: { [emailType]: enabled },
});
}, },
onMutate: async ({ contribuyenteId, emailType, enabled }) => { onMutate: async ({ emailType, role, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] }); await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']); const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
if (previous) { if (previous) {
queryClient.setQueryData<ListResponse>(['notification-preferences'], { queryClient.setQueryData<ListResponse>(['notification-preferences'], {
...previous, ...previous,
data: previous.data.map(c => preferences: {
c.contribuyenteId === contribuyenteId ...previous.preferences,
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } } [emailType]: {
: c, ...previous.preferences[emailType],
), [role]: enabled,
},
},
}); });
} }
return { previous }; return { previous };
@@ -97,6 +92,9 @@ export default function NotificacionesPage() {
}, },
}); });
const roles = data?.roles ?? [];
const emailTypes = data?.emailTypes ?? [];
return ( return (
<> <>
<Header title="Notificaciones" /> <Header title="Notificaciones" />
@@ -105,10 +103,10 @@ export default function NotificacionesPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
Correos informativos por contribuyente Correos informativos por rol
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración. Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -118,71 +116,68 @@ export default function NotificacionesPage() {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Cargando... Cargando...
</div> </div>
) : visibles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{selectedContribuyenteId
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
: 'No hay contribuyentes en este despacho.'}
</CardContent>
</Card>
) : ( ) : (
visibles.map(contrib => ( <Card>
<Card key={contrib.contribuyenteId}> <CardContent className="p-0 overflow-x-auto">
<CardHeader> <table className="w-full text-sm">
<CardTitle className="text-sm font-medium"> <thead>
{contrib.nombre} <tr className="border-b bg-muted/50">
</CardTitle> <th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription> {roles.map(role => (
</CardHeader> <th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
<CardContent> {ROLE_LABELS[role] ?? role}
<div className="space-y-3"> </th>
{(data?.emailTypes ?? []).map(type => { ))}
</tr>
</thead>
<tbody>
{emailTypes.map(type => {
const meta = EMAIL_LABELS[type]; const meta = EMAIL_LABELS[type];
if (!meta) return null; if (!meta) return null;
const checked = contrib.preferences[type] !== false;
const isPending = meta.status === 'pending'; const isPending = meta.status === 'pending';
const isConfigurable = meta.configurable;
return ( return (
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0"> <tr key={type} className="border-b last:border-0">
<div className="flex-1 min-w-0"> <td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{meta.label}</span> <span className="font-medium">{meta.label}</span>
{isPending ? ( {isPending && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5"> <span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
Próximamente Próximamente
</span> </span>
) : !isConfigurable ? ( )}
<span className="text-[10px] uppercase tracking-wide bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 rounded px-1.5 py-0.5">
A nivel despacho
</span>
) : null}
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p> <p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div> </td>
<label className={`inline-flex items-center flex-shrink-0 ${isConfigurable && !isPending ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}> {roles.map(role => {
const checked = data?.preferences?.[type]?.[role] !== false;
return (
<td key={role} className="px-4 py-3 text-center align-middle">
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input <input
type="checkbox" type="checkbox"
className="sr-only peer" className="sr-only peer"
checked={checked} checked={checked}
disabled={!isConfigurable || isPending} disabled={isPending}
onChange={e => onChange={e =>
mutation.mutate({ mutation.mutate({
contribuyenteId: contrib.contribuyenteId,
emailType: type, emailType: type,
role,
enabled: e.target.checked, enabled: e.target.checked,
}) })
} }
/> />
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" /> <div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label> </label>
</div> </td>
); );
})} })}
</div> </tr>
);
})}
</tbody>
</table>
</CardContent> </CardContent>
</Card> </Card>
))
)} )}
</main> </main>
</> </>