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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
54
apps/api/src/events/recipients.ts
Normal file
54
apps/api/src/events/recipients.ts
Normal 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] };
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
41
apps/api/src/middleware/requireAdmin.ts
Normal file
41
apps/api/src/middleware/requireAdmin.ts
Normal 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();
|
||||
}
|
||||
184
apps/api/src/routes/notificationConfig.ts
Normal file
184
apps/api/src/routes/notificationConfig.ts
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user