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

@@ -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({