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

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