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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user