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({
|
||||
|
||||
Reference in New Issue
Block a user