feat: configuración de destinatarios de notificaciones desde la app

Gestiona grupos de email y asignación por escenario en BD
(natur_reservas.notification_groups / notification_scenario_recipients)
en lugar de variables de entorno hardcodeadas. Incluye pantalla de ajustes,
endpoint admin protegido, envío de prueba y niveles reducida/completa.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:12:58 +01:00
parent 5656ad13ad
commit ee03f0b36d
19 changed files with 991 additions and 103 deletions

View File

@@ -17,9 +17,10 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
N8N_EMAIL_WEBHOOK_URL=https://n8n.tudominio.com/webhook/naturcalabacera-email
EMAIL_FROM=Naturcalabacera <reservas@tudominio.com>
# Destinatarios de notificaciones (parametrizables, sin hardcodear)
NOTIFICATION_EMAIL_TENERIFFA=contacto-teneriffa@ejemplo.com
NOTIFICATION_EMAIL_NATUR=admin@naturcalabacera.com
# Destinatarios de notificaciones (parametrizables)
# Teneriffa admite VARIOS correos separados por coma (correo reducido).
NOTIFICATION_EMAIL_TENERIFFA=uwe.dotschkis@teneriffa2000.de, j.reichstein@teneriffa2000.de
NOTIFICATION_EMAIL_NATUR=administracion@naturcalabacera.com # correo completo
NOTIFICATION_EMAIL_POOL_HEATING=jonathan@ejemplo.com # "Jonathan o las chicas"
# URL del frontend para CORS

View File

@@ -26,10 +26,12 @@ export const env = {
N8N_EMAIL_WEBHOOK_URL: required('N8N_EMAIL_WEBHOOK_URL'),
EMAIL_FROM: optional('EMAIL_FROM', 'Naturcalabacera <reservas@naturcalabacera.com>'),
// Destinatarios de notificaciones (configurables, sin hardcodear)
NOTIFICATION_EMAIL_TENERIFFA: required('NOTIFICATION_EMAIL_TENERIFFA'),
NOTIFICATION_EMAIL_NATUR: required('NOTIFICATION_EMAIL_NATUR'),
NOTIFICATION_EMAIL_POOL_HEATING: required('NOTIFICATION_EMAIL_POOL_HEATING'),
// DEPRECADO: los destinatarios ahora se gestionan en la BD (tablas notification_groups /
// notification_scenario_recipients) desde la pantalla de la app. Se mantienen como opcionales
// por compatibilidad, pero el motor de envío ya NO los usa.
NOTIFICATION_EMAIL_TENERIFFA: optional('NOTIFICATION_EMAIL_TENERIFFA', ''),
NOTIFICATION_EMAIL_NATUR: optional('NOTIFICATION_EMAIL_NATUR', ''),
NOTIFICATION_EMAIL_POOL_HEATING: optional('NOTIFICATION_EMAIL_POOL_HEATING', ''),
// Jobs
JOB_RUNNER_INTERVAL_MS: parseInt(optional('JOB_RUNNER_INTERVAL_MS', '300000'), 10), // 5 min

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { sendEmail } from '../services/email.js';
import { env } from '../config/env.js';
import { resolveRecipients, type ResolvedRecipients } from './recipients.js';
import type { Reservation, NotificationEventType } from '@naturcalabacera/shared';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -176,6 +176,7 @@ function renderTeneriffaMinimal(
dateRange: string,
property: string,
cancelled = false,
changesBlock = '',
): string {
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
return `<!DOCTYPE html>
@@ -190,12 +191,15 @@ function renderTeneriffaMinimal(
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
${changesBlock}
</div>
</div>
</body></html>`;
}
async function sendCrudPair(opts: {
reducidaEmails: string[];
completaEmails: string[];
actionLabel: string;
subjectTag: string;
dateRange: string;
@@ -205,28 +209,29 @@ async function sendCrudPair(opts: {
originLabel: string;
clientName: string;
cancelled?: boolean;
/** Tabla de cambios (solo modificaciones). Se muestra también en la versión reducida. */
changesBlock?: string;
}): Promise<SendResult> {
// 1) Correo reducido a Teneriffa (solo fechas + propiedad)
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled);
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
const minRes = await sendEmail({
to: env.NOTIFICATION_EMAIL_TENERIFFA,
subject: minSubject,
html: minHtml,
});
let lastFail: SendResult | null = null;
// 2) Correo completo a Natur (HTML con todos los detalles)
const fullHtml = renderTemplate(opts.template, opts.vars);
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel}${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
const fullRes = await sendEmail({
to: env.NOTIFICATION_EMAIL_NATUR,
subject: fullSubject,
html: fullHtml,
});
// 1) Versión REDUCIDA (fechas + propiedad, y la tabla de cambios si la hay) → grupos nivel 'reducida'.
// Por diseño nunca incluye datos del cliente: imposible filtrar a Teneriffa.
if (opts.reducidaEmails.length > 0) {
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled, opts.changesBlock ?? '');
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
const r = await sendEmail({ to: opts.reducidaEmails, subject: minSubject, html: minHtml });
if (!r.success) lastFail = r;
}
if (!minRes.success) return minRes;
if (!fullRes.success) return fullRes;
return { success: true };
// 2) Versión COMPLETA (HTML con todos los detalles) → grupos nivel 'completa'.
if (opts.completaEmails.length > 0) {
const fullHtml = renderTemplate(opts.template, opts.vars);
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel}${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
const r = await sendEmail({ to: opts.completaEmails, subject: fullSubject, html: fullHtml });
if (!r.success) lastFail = r;
}
return lastFail ?? { success: true };
}
/**
@@ -236,7 +241,8 @@ async function sendCrudPair(opts: {
export async function handleNotificationEvent(
eventType: NotificationEventType,
reservation: Reservation,
previousReservation?: Reservation
previousReservation?: Reservation,
overrideRecipients?: ResolvedRecipients
): Promise<SendResult> {
const nights = nightsCount(reservation.start_date, reservation.end_date);
const dateRange = `${formatDateShort(reservation.start_date)} ${formatDateShort(reservation.end_date)}`;
@@ -261,6 +267,10 @@ export async function handleNotificationEvent(
CANCEL_ALERT: '',
};
// Destinatarios configurados para este escenario (desde la BD, no env vars).
// overrideRecipients permite el envío de prueba a una dirección concreta.
const recipients = overrideRecipients ?? await resolveRecipients(eventType);
switch (eventType) {
case 'reservation.created': {
const isTeenriffa = reservation.origin === 'Teneriffa2000';
@@ -273,6 +283,8 @@ export async function handleNotificationEvent(
CHANGES_BLOCK: '',
};
return sendCrudPair({
reducidaEmails: recipients.reducida,
completaEmails: recipients.completa,
actionLabel: 'Nueva Reserva',
subjectTag: 'NUEVA RESERVA',
dateRange,
@@ -303,6 +315,8 @@ export async function handleNotificationEvent(
CHANGES_BLOCK: changesBlock,
};
return sendCrudPair({
reducidaEmails: recipients.reducida,
completaEmails: recipients.completa,
actionLabel: 'Reserva Modificada',
subjectTag: 'MODIFICADA',
dateRange,
@@ -311,6 +325,7 @@ export async function handleNotificationEvent(
vars,
originLabel,
clientName: reservation.client_name,
changesBlock,
});
}
@@ -326,6 +341,8 @@ export async function handleNotificationEvent(
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
};
return sendCrudPair({
reducidaEmails: recipients.reducida,
completaEmails: recipients.completa,
actionLabel: 'Reserva Cancelada',
subjectTag: 'CANCELADA',
dateRange,
@@ -338,22 +355,18 @@ export async function handleNotificationEvent(
});
}
case 'reservation.reminder_24h': {
const html = renderTemplate('reminder-24h', baseVars);
const subject = `Recordatorio: Check-in mañana — ${reservation.client_name} (Los Dragos)`;
return sendEmail({ to: env.NOTIFICATION_EMAIL_TENERIFFA, subject, html });
}
case 'reservation.invoice_second_notice': {
if (recipients.all.length === 0) return { success: true };
const html = renderTemplate('invoice-10d', baseVars);
const subject = `Segunda factura en 10 días — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
return sendEmail({ to: env.NOTIFICATION_EMAIL_NATUR, subject, html });
return sendEmail({ to: recipients.all, subject, html });
}
case 'reservation.pool_heating_notice': {
if (recipients.all.length === 0) return { success: true };
const html = renderTemplate('pool-heating-48h', baseVars);
const subject = `Calefacción de piscina en 48h — ${reservation.client_name} (${propertyLabel(reservation.property)})`;
return sendEmail({ to: env.NOTIFICATION_EMAIL_POOL_HEATING, subject, html });
return sendEmail({ to: recipients.all, subject, html });
}
default:

View File

@@ -0,0 +1,54 @@
import { supabaseAdmin } from '../lib/supabase.js';
import type { NotificationEventType } from '@naturcalabacera/shared';
export interface ResolvedRecipients {
/** Emails de grupos nivel 'reducida' (solo reciben la versión reducida). */
reducida: string[];
/** Emails de grupos nivel 'completa'. */
completa: string[];
/** Unión de ambos, para escenarios de plantilla única (factura, piscina). */
all: string[];
}
interface JoinedRow {
notification_groups: {
level: 'reducida' | 'completa';
emails: string[] | null;
enabled: boolean;
} | null;
}
/**
* Resuelve los destinatarios de un escenario leyendo la configuración de la BD
* (notification_scenario_recipients + notification_groups). Agrupa por nivel.
*
* Garantía de privacidad: los emails de grupos 'reducida' nunca aparecen en la
* lista 'completa', por lo que es imposible enviarles la versión completa.
*/
export async function resolveRecipients(scenario: NotificationEventType): Promise<ResolvedRecipients> {
const { data, error } = await supabaseAdmin
.from('notification_scenario_recipients')
.select('notification_groups(level, emails, enabled)')
.eq('scenario', scenario);
if (error) {
console.error(`[recipients] Error al resolver destinatarios de ${scenario}:`, error.message);
return { reducida: [], completa: [], all: [] };
}
const reducida = new Set<string>();
const completa = new Set<string>();
for (const row of (data ?? []) as unknown as JoinedRow[]) {
const g = row.notification_groups;
if (!g || !g.enabled) continue;
const bucket = g.level === 'reducida' ? reducida : completa;
for (const email of g.emails ?? []) {
const clean = email.trim();
if (clean) bucket.add(clean);
}
}
const all = new Set<string>([...reducida, ...completa]);
return { reducida: [...reducida], completa: [...completa], all: [...all] };
}

View File

@@ -33,13 +33,6 @@ export interface EventDescriptor {
* Añadir un nuevo tipo de notificación = añadir una entrada aquí.
*/
export const EVENT_DESCRIPTORS: EventDescriptor[] = [
{
eventType: 'reservation.reminder_24h',
conditions: { origin: 'Teneriffa2000', property: 'los_dragos' },
recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA',
templateName: 'reminder-24h',
offsetDays: 1,
},
{
eventType: 'reservation.invoice_second_notice',
conditions: { origin: 'Naturcalabacera' },

View File

@@ -3,6 +3,7 @@ import cors from 'cors';
import { env } from './config/env.js';
import { healthRouter } from './routes/health.js';
import { notificationsRouter } from './routes/notifications.js';
import { notificationConfigRouter } from './routes/notificationConfig.js';
import { usersRouter } from './routes/users.js';
import { runPendingJobs } from './jobs/runner.js';
@@ -18,6 +19,7 @@ app.use(express.json());
// Rutas
app.use('/health', healthRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/notification-config', notificationConfigRouter);
app.use('/api/users', usersRouter);
// Arrancar servidor

View File

@@ -113,21 +113,7 @@ export async function scheduleNotificationsForReservation(
// El email CRUD se envía directamente en la ruta, no se encola aquí.
// 2. Recordatorio 24h antes — solo Teneriffa + Los Dragos
if (reservation.origin === 'Teneriffa2000' && reservation.property === 'los_dragos') {
const scheduled = new Date(startDate);
scheduled.setUTCHours(scheduled.getUTCHours() - 24);
if (scheduled > new Date()) {
eventsToInsert.push({
reservation_id: reservation.id,
event_type: 'reservation.reminder_24h',
scheduled_for: scheduled.toISOString(),
status: 'pending',
});
}
}
// 3. Segunda factura 10 días antes — solo Natur
// 1. Segunda factura 10 días antes — solo Natur
if (reservation.origin === 'Naturcalabacera') {
const scheduled = new Date(startDate);
scheduled.setUTCDate(scheduled.getUTCDate() - 10);
@@ -141,7 +127,7 @@ export async function scheduleNotificationsForReservation(
}
}
// 4. Aviso calefacción piscina 48h antes
// 2. Aviso calefacción piscina 48h antes
if (reservation.has_pool_heating) {
const scheduled = new Date(startDate);
scheduled.setUTCHours(scheduled.getUTCHours() - 48);

View File

@@ -0,0 +1,41 @@
import type { Request, Response, NextFunction } from 'express';
import { supabaseAdmin } from '../lib/supabase.js';
/**
* Middleware: extrae el JWT del header Authorization, valida la sesión con
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
* caller en res.locals.callerId.
*/
export async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.status(401).json({ error: 'Falta token de autenticación' });
return;
}
const token = auth.slice('Bearer '.length);
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
if (userError || !userData.user) {
res.status(401).json({ error: 'Token inválido' });
return;
}
const { data: profile, error: profileError } = await supabaseAdmin
.from('user_profiles')
.select('role')
.eq('id', userData.user.id)
.single();
if (profileError || !profile) {
res.status(403).json({ error: 'Perfil no encontrado' });
return;
}
if (profile.role !== 'admin') {
res.status(403).json({ error: 'Solo admins pueden realizar esta acción' });
return;
}
res.locals.callerId = userData.user.id;
next();
}

View File

@@ -0,0 +1,184 @@
import { Router, type Router as ExpressRouter } from 'express';
import { supabaseAdmin } from '../lib/supabase.js';
import { requireAdmin } from '../middleware/requireAdmin.js';
import { handleNotificationEvent } from '../events/handler.js';
import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared';
import type { NotificationGroup, NotificationEventType, Reservation } from '@naturcalabacera/shared';
const router: ExpressRouter = Router();
const SCENARIOS = CONFIGURABLE_SCENARIOS.map(s => s.scenario);
const VALID_LEVELS = ['reducida', 'completa'];
/**
* GET /api/notification-config
* Devuelve los grupos y la asignación de escenarios.
*/
router.get('/', requireAdmin, async (_req, res) => {
const { data: groups, error: gErr } = await supabaseAdmin
.from('notification_groups')
.select('*')
.order('created_at', { ascending: true });
if (gErr) return res.status(500).json({ error: gErr.message });
const { data: rows, error: rErr } = await supabaseAdmin
.from('notification_scenario_recipients')
.select('scenario, group_id');
if (rErr) return res.status(500).json({ error: rErr.message });
const assignments: Record<string, string[]> = {};
for (const r of rows ?? []) {
(assignments[r.scenario] ??= []).push(r.group_id);
}
return res.json({ groups: groups ?? [], assignments });
});
/**
* PUT /api/notification-config
* Reemplaza el estado completo: grupos + asignaciones.
* Los grupos nuevos llegan con un id temporal "tmp_..."; se mapean a su id real.
*/
router.put('/', requireAdmin, async (req, res) => {
const { groups, assignments } = req.body as {
groups: NotificationGroup[];
assignments: Record<string, string[]>;
};
if (!Array.isArray(groups) || typeof assignments !== 'object' || assignments === null) {
return res.status(400).json({ error: 'Formato inválido: se esperan groups[] y assignments{}' });
}
// Validación de grupos
for (const g of groups) {
if (!g.name || !g.name.trim()) return res.status(400).json({ error: 'Todos los grupos necesitan nombre' });
if (!VALID_LEVELS.includes(g.level)) return res.status(400).json({ error: `Nivel inválido en "${g.name}"` });
if (!Array.isArray(g.emails)) return res.status(400).json({ error: `Emails inválidos en "${g.name}"` });
}
const existing = groups.filter(g => g.id && !g.id.startsWith('tmp_'));
const nuevos = groups.filter(g => !g.id || g.id.startsWith('tmp_'));
const now = new Date().toISOString();
try {
// 1) Borrar grupos que ya no están (cascade elimina sus asignaciones)
const { data: current, error: curErr } = await supabaseAdmin.from('notification_groups').select('id');
if (curErr) throw new Error(curErr.message);
const keepIds = existing.map(g => g.id);
const toDelete = (current ?? []).map(c => c.id).filter(id => !keepIds.includes(id));
if (toDelete.length > 0) {
const { error } = await supabaseAdmin.from('notification_groups').delete().in('id', toDelete);
if (error) throw new Error(error.message);
}
// 2) Actualizar grupos existentes
for (const g of existing) {
const { error } = await supabaseAdmin
.from('notification_groups')
.update({
name: g.name.trim(),
level: g.level,
emails: g.emails.map(e => e.trim()).filter(Boolean),
enabled: g.enabled !== false,
updated_at: now,
})
.eq('id', g.id);
if (error) throw new Error(error.message);
}
// 3) Insertar grupos nuevos y mapear tmp_id → id real
const idMap: Record<string, string> = {};
for (const g of nuevos) {
const { data, error } = await supabaseAdmin
.from('notification_groups')
.insert({
name: g.name.trim(),
level: g.level,
emails: g.emails.map(e => e.trim()).filter(Boolean),
enabled: g.enabled !== false,
})
.select('id')
.single();
if (error) throw new Error(error.message);
if (g.id) idMap[g.id] = data.id;
}
// 4) Reemplazar asignaciones de los escenarios conocidos
const { error: delErr } = await supabaseAdmin
.from('notification_scenario_recipients')
.delete()
.in('scenario', SCENARIOS);
if (delErr) throw new Error(delErr.message);
const rows: { scenario: string; group_id: string }[] = [];
for (const [scenario, groupIds] of Object.entries(assignments)) {
if (!SCENARIOS.includes(scenario as NotificationEventType)) continue;
for (const gid of groupIds) {
const realId = idMap[gid] ?? gid;
rows.push({ scenario, group_id: realId });
}
}
if (rows.length > 0) {
const { error } = await supabaseAdmin.from('notification_scenario_recipients').insert(rows);
if (error) throw new Error(error.message);
}
return res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Error al guardar';
console.error('[notification-config] Error al guardar:', message);
return res.status(500).json({ error: message });
}
});
/**
* POST /api/notification-config/test
* Envía un correo de prueba de un escenario con datos ficticios.
* Si se indica testEmail, se envía solo a esa dirección (incluye versión reducida
* y completa para los escenarios duales). Si no, va a los destinatarios reales.
*/
router.post('/test', requireAdmin, async (req, res) => {
const { scenario, testEmail } = req.body as { scenario?: NotificationEventType; testEmail?: string };
if (!scenario || !SCENARIOS.includes(scenario)) {
return res.status(400).json({ error: 'Escenario inválido' });
}
const fake = buildFakeReservation();
const previous = scenario === 'reservation.updated'
? { ...fake, start_date: '2026-10-08', end_date: '2026-10-12' }
: undefined;
const override = testEmail && testEmail.trim()
? { reducida: [testEmail.trim()], completa: [testEmail.trim()], all: [testEmail.trim()] }
: undefined;
const result = await handleNotificationEvent(scenario, fake, previous, override);
if (!result.success) {
return res.status(502).json({ error: result.error ?? 'Fallo al enviar la prueba' });
}
return res.json({ ok: true });
});
/** Reserva ficticia coherente para los correos de prueba. */
function buildFakeReservation(): Reservation {
return {
id: 'test-0000',
client_name: 'Cliente de Prueba',
start_date: '2026-10-15',
end_date: '2026-10-20',
property: 'los_dragos',
origin: 'Naturcalabacera',
adults_count: 2,
children_count: 2,
has_cleaning: true,
has_pool_heating: true,
has_flies_products: false,
government_registration: null,
observations: 'Reserva de prueba generada desde la pantalla de configuración.',
invoice_number: null,
is_event: false,
} as unknown as Reservation;
}
export { router as notificationConfigRouter };

View File

@@ -1,50 +1,12 @@
import { Router, type Request, type Response, type NextFunction, type Router as ExpressRouter } from 'express';
import { Router, type Router as ExpressRouter } from 'express';
import { supabaseAdmin } from '../lib/supabase.js';
import { requireAdmin } from '../middleware/requireAdmin.js';
import type { UserRole } from '@naturcalabacera/shared';
const router: ExpressRouter = Router();
const VALID_ROLES: UserRole[] = ['admin', 'internal_staff', 'external_availability_viewer'];
/**
* Middleware: extrae el JWT del header Authorization, valida la sesión con
* Supabase y comprueba que el usuario es admin. Si pasa, deja el id del
* caller en res.locals.callerId.
*/
async function requireAdmin(req: Request, res: Response, next: NextFunction): Promise<void> {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.status(401).json({ error: 'Falta token de autenticación' });
return;
}
const token = auth.slice('Bearer '.length);
const { data: userData, error: userError } = await supabaseAdmin.auth.getUser(token);
if (userError || !userData.user) {
res.status(401).json({ error: 'Token inválido' });
return;
}
const { data: profile, error: profileError } = await supabaseAdmin
.from('user_profiles')
.select('role')
.eq('id', userData.user.id)
.single();
if (profileError || !profile) {
res.status(403).json({ error: 'Perfil no encontrado' });
return;
}
if (profile.role !== 'admin') {
res.status(403).json({ error: 'Solo admins pueden gestionar usuarios' });
return;
}
res.locals.callerId = userData.user.id;
next();
}
// GET /api/users — listar todos los perfiles
router.get('/', requireAdmin, async (_req, res) => {
const { data, error } = await supabaseAdmin