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:
@@ -3,29 +3,42 @@ import { z } from 'zod';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import {
|
||||
EMAIL_TYPES,
|
||||
getEmailPreferencesPorContribuyente,
|
||||
setContribuyenteEmailPreferences,
|
||||
NOTIFICATION_ROLES,
|
||||
getRoleEmailPreferences,
|
||||
setRoleEmailPreference,
|
||||
type EmailType,
|
||||
type NotificationRole,
|
||||
} from '../services/notification-preferences.service.js';
|
||||
|
||||
export async function listPreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!);
|
||||
res.json({ emailTypes: EMAIL_TYPES, data });
|
||||
const preferences = await getRoleEmailPreferences(req.tenantPool!);
|
||||
res.json({
|
||||
emailTypes: EMAIL_TYPES,
|
||||
roles: NOTIFICATION_ROLES,
|
||||
preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSchema = z.object({
|
||||
contribuyenteId: z.string().uuid(),
|
||||
preferences: z.record(z.string(), z.boolean()),
|
||||
emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
|
||||
role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { contribuyenteId, preferences } = updateSchema.parse(req.body);
|
||||
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences);
|
||||
res.json({ contribuyenteId, preferences: updated });
|
||||
const { emailType, role, enabled } = updateSchema.parse(req.body);
|
||||
const preferences = await setRoleEmailPreference(
|
||||
req.tenantPool!,
|
||||
emailType as EmailType,
|
||||
role as NotificationRole,
|
||||
enabled,
|
||||
);
|
||||
res.json({ preferences });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
|
||||
next(error);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
|
||||
import { getKpis } from '../services/dashboard.service.js';
|
||||
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.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
|
||||
|
||||
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
|
||||
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({
|
||||
where: { tenantId, isOwner: true, active: true },
|
||||
include: { user: { select: { email: true, nombre: true, active: true } } },
|
||||
});
|
||||
const recipients = owners.filter(o => o.user.active);
|
||||
if (recipients.length === 0) {
|
||||
const activeOwners = owners.filter(o => o.user.active);
|
||||
if (activeOwners.length === 0) {
|
||||
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
|
||||
return { sent: 0 };
|
||||
}
|
||||
|
||||
// Pool del tenant para queries de CFDI
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
|
||||
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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,30 +1,49 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Tipos de correos informativos cuyo envío puede desactivarse por
|
||||
* contribuyente. NO incluye correos transaccionales críticos
|
||||
* (welcome, password-reset, payment-*) — esos siempre se envían.
|
||||
* Tipos de correos informativos cuyo envío puede desactivarse por rol.
|
||||
* NO incluye correos transaccionales críticos (welcome, password-reset,
|
||||
* payment-*, invitaciones) — esos siempre se envían.
|
||||
*
|
||||
* Estado de implementación:
|
||||
* - documento_subido: ✅ implementado y configurable por contribuyente
|
||||
* - weekly_update: ✅ implementado (job tenant-wide, no configurable)
|
||||
* - subscription_expiring: ✅ implementado (aviso a owner, no configurable)
|
||||
* - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
|
||||
* - weekly_update: ✅ implementado (job tenant-wide, owners)
|
||||
* - subscription_expiring: ✅ implementado (aviso a owner)
|
||||
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
||||
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
|
||||
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
|
||||
*/
|
||||
export const EMAIL_TYPES = [
|
||||
'documento_subido',
|
||||
'weekly_update',
|
||||
'subscription_expiring',
|
||||
'recordatorio_fiscal',
|
||||
'alertas_nuevas',
|
||||
'recordatorio_proximo',
|
||||
] as const;
|
||||
|
||||
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 RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
|
||||
|
||||
/**
|
||||
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
|
||||
* key, asumimos `true` para preservar el comportamiento previo.
|
||||
* Default legacy (por contribuyente). Se mantiene por compatibilidad con la
|
||||
* columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
|
||||
*/
|
||||
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
||||
const out = {} as EmailPreferences;
|
||||
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
|
||||
* activado) si no hay fila o la columna está vacía.
|
||||
*/
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Preferencias por contribuyente (legacy — conservado por compatibilidad)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function getContribuyenteEmailPreferences(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
|
||||
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(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
|
||||
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(
|
||||
pool: Pool,
|
||||
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
||||
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
|
||||
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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '../config/database.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.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';
|
||||
|
||||
/**
|
||||
@@ -34,10 +34,7 @@ export async function notifyDocumentoSubido(params: {
|
||||
// subject informativo ni supervisor — skip.
|
||||
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<{
|
||||
rfc: string;
|
||||
@@ -54,14 +51,17 @@ export async function notifyDocumentoSubido(params: {
|
||||
const contrib = rows[0];
|
||||
|
||||
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
||||
const owners = await getTenantOwnerEmails(tenantId);
|
||||
const recipients = new Set<string>(owners);
|
||||
const ownerEmails = await getTenantOwnerEmails(tenantId);
|
||||
const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
|
||||
|
||||
if (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.
|
||||
recipients.delete(subidoPor.toLowerCase());
|
||||
recipients.delete(subidoPor);
|
||||
|
||||
@@ -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 { emailService } from '../email/email.service.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 { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
|
||||
import {
|
||||
@@ -1191,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
||||
{ 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;
|
||||
@@ -1235,33 +1236,48 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
|
||||
|
||||
// Hay algo que avisar.
|
||||
try {
|
||||
const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
|
||||
if (!ownerEmail) {
|
||||
// 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);
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
|
||||
if (isTrialFlow) {
|
||||
if (bucket === 0) {
|
||||
await emailService.sendTrialExpired(ownerEmail, {
|
||||
nombre: sub.tenant.nombre,
|
||||
despachoNombre: sub.tenant.nombre,
|
||||
});
|
||||
for (const ownerEmail of emailsToNotify) {
|
||||
if (isTrialFlow) {
|
||||
if (bucket === 0) {
|
||||
await emailService.sendTrialExpired(ownerEmail, {
|
||||
nombre: sub.tenant.nombre,
|
||||
despachoNombre: sub.tenant.nombre,
|
||||
});
|
||||
} else {
|
||||
await emailService.sendTrialReminder(ownerEmail, {
|
||||
nombre: sub.tenant.nombre,
|
||||
despachoNombre: sub.tenant.nombre,
|
||||
diasRestantes: Math.max(0, daysUntil),
|
||||
wizardCompleto: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await emailService.sendTrialReminder(ownerEmail, {
|
||||
await emailService.sendSubscriptionExpiring(ownerEmail, {
|
||||
nombre: sub.tenant.nombre,
|
||||
despachoNombre: sub.tenant.nombre,
|
||||
diasRestantes: Math.max(0, daysUntil),
|
||||
wizardCompleto: true,
|
||||
plan: sub.plan,
|
||||
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await emailService.sendSubscriptionExpiring(ownerEmail, {
|
||||
nombre: sub.tenant.nombre,
|
||||
plan: sub.plan,
|
||||
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.subscription.update({
|
||||
|
||||
@@ -1,55 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
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: {
|
||||
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',
|
||||
configurable: true,
|
||||
},
|
||||
weekly_update: {
|
||||
label: 'Reporte semanal',
|
||||
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
|
||||
status: 'active',
|
||||
configurable: false,
|
||||
},
|
||||
subscription_expiring: {
|
||||
label: 'Vencimiento de suscripción',
|
||||
description: 'Aviso cuando la suscripción del despacho está por vencer.',
|
||||
status: 'active',
|
||||
configurable: false,
|
||||
},
|
||||
recordatorio_fiscal: {
|
||||
label: 'Recordatorios fiscales',
|
||||
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
|
||||
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 {
|
||||
emailTypes: string[];
|
||||
data: ContribuyentePrefs[];
|
||||
roles: string[];
|
||||
preferences: Record<string, Record<string, boolean>>;
|
||||
}
|
||||
|
||||
export default function NotificacionesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
|
||||
const { data, isLoading } = useQuery<ListResponse>({
|
||||
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({
|
||||
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => {
|
||||
await apiClient.put('/notificaciones', {
|
||||
contribuyenteId,
|
||||
preferences: { [emailType]: enabled },
|
||||
});
|
||||
mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
|
||||
await apiClient.put('/notificaciones', { emailType, role, enabled });
|
||||
},
|
||||
onMutate: async ({ contribuyenteId, emailType, enabled }) => {
|
||||
onMutate: async ({ emailType, role, enabled }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
|
||||
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
|
||||
if (previous) {
|
||||
queryClient.setQueryData<ListResponse>(['notification-preferences'], {
|
||||
...previous,
|
||||
data: previous.data.map(c =>
|
||||
c.contribuyenteId === contribuyenteId
|
||||
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } }
|
||||
: c,
|
||||
),
|
||||
preferences: {
|
||||
...previous.preferences,
|
||||
[emailType]: {
|
||||
...previous.preferences[emailType],
|
||||
[role]: enabled,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return { previous };
|
||||
@@ -97,6 +92,9 @@ export default function NotificacionesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const roles = data?.roles ?? [];
|
||||
const emailTypes = data?.emailTypes ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Notificaciones" />
|
||||
@@ -105,10 +103,10 @@ export default function NotificacionesPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="h-4 w-4" />
|
||||
Correos informativos por contribuyente
|
||||
Correos informativos por rol
|
||||
</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -118,71 +116,68 @@ export default function NotificacionesPage() {
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Cargando...
|
||||
</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 key={contrib.contribuyenteId}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{contrib.nombre}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(data?.emailTypes ?? []).map(type => {
|
||||
<Card>
|
||||
<CardContent className="p-0 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
|
||||
{roles.map(role => (
|
||||
<th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
|
||||
{ROLE_LABELS[role] ?? role}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emailTypes.map(type => {
|
||||
const meta = EMAIL_LABELS[type];
|
||||
if (!meta) return null;
|
||||
const checked = contrib.preferences[type] !== false;
|
||||
const isPending = meta.status === 'pending';
|
||||
const isConfigurable = meta.configurable;
|
||||
return (
|
||||
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<tr key={type} className="border-b last:border-0">
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{meta.label}</span>
|
||||
{isPending ? (
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{isPending && (
|
||||
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
|
||||
Próximamente
|
||||
</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>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
|
||||
</div>
|
||||
<label className={`inline-flex items-center flex-shrink-0 ${isConfigurable && !isPending ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
disabled={!isConfigurable || isPending}
|
||||
onChange={e =>
|
||||
mutation.mutate({
|
||||
contribuyenteId: contrib.contribuyenteId,
|
||||
emailType: type,
|
||||
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" />
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
{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
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
disabled={isPending}
|
||||
onChange={e =>
|
||||
mutation.mutate({
|
||||
emailType: type,
|
||||
role,
|
||||
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" />
|
||||
</label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user