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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,3 +30,4 @@ apps/api/preview-out/
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.superpowers/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SearchBar } from './components/SearchBar';
|
||||
import { SettingsPage } from './components/SettingsPage';
|
||||
import { YearlyCalendar } from './components/YearlyCalendar';
|
||||
import { UserManagement } from './components/UserManagement';
|
||||
import { NotificationSettings } from './components/NotificationSettings';
|
||||
import { LoginPage } from './components/LoginPage';
|
||||
import { ChatbotContainer } from './components/ChatbotContainer';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
@@ -196,6 +197,7 @@ function AppContent() {
|
||||
/>
|
||||
)}
|
||||
{currentView === 'users' && isAdmin && <UserManagement />}
|
||||
{currentView === 'notifications' && isAdmin && <NotificationSettings />}
|
||||
</main>
|
||||
|
||||
<MobileNavigation currentView={currentView} onNavigate={setCurrentView} isViewer={isViewer} isAdmin={isAdmin} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Calendar, Settings, CalendarDays, Home, Users } from 'lucide-react';
|
||||
import { Calendar, Settings, CalendarDays, Home, Users, Bell } from 'lucide-react';
|
||||
import { useProperty } from '../contexts/PropertyContext';
|
||||
import { PROPERTY_CONFIG, PROPERTIES } from '@naturcalabacera/shared';
|
||||
import type { Property } from '@naturcalabacera/shared';
|
||||
@@ -16,6 +16,7 @@ export function MobileNavigation({ currentView, onNavigate, isViewer = false, is
|
||||
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
|
||||
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
|
||||
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
|
||||
{ id: 'notifications', label: 'Avisos', icon: Bell, requires: 'admin' as const },
|
||||
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
|
||||
];
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
|
||||
308
apps/web/src/components/NotificationSettings.tsx
Normal file
308
apps/web/src/components/NotificationSettings.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, Save, Plus, Trash2, X, Send, Lock, Bell, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { CONFIGURABLE_SCENARIOS } from '@naturcalabacera/shared';
|
||||
import type { NotificationGroup, NotificationLevel } from '@naturcalabacera/shared';
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
async function authFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data.session?.access_token;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
return fetch(`${apiUrl}${path}`, { ...init, headers });
|
||||
}
|
||||
|
||||
const newTmpId = () => `tmp_${crypto.randomUUID()}`;
|
||||
|
||||
export function NotificationSettings() {
|
||||
const [groups, setGroups] = useState<NotificationGroup[]>([]);
|
||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({});
|
||||
const [emailDraft, setEmailDraft] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authFetch('/api/notification-config');
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||
}
|
||||
const body = await res.json() as { groups: NotificationGroup[]; assignments: Record<string, string[]> };
|
||||
setGroups(body.groups);
|
||||
setAssignments(body.assignments ?? {});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Error al cargar la configuración');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grupos ──
|
||||
const patchGroup = (id: string, patch: Partial<NotificationGroup>) =>
|
||||
setGroups(gs => gs.map(g => (g.id === id ? { ...g, ...patch } : g)));
|
||||
|
||||
const addGroup = () =>
|
||||
setGroups(gs => [...gs, { id: newTmpId(), name: 'Nuevo grupo', level: 'completa', emails: [], enabled: true }]);
|
||||
|
||||
const removeGroup = (id: string) => {
|
||||
setGroups(gs => gs.filter(g => g.id !== id));
|
||||
setAssignments(a => {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const [sc, ids] of Object.entries(a)) next[sc] = ids.filter(x => x !== id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addEmail = (groupId: string) => {
|
||||
const raw = (emailDraft[groupId] ?? '').trim();
|
||||
if (!raw) return;
|
||||
patchGroup(groupId, { emails: [...(groups.find(g => g.id === groupId)?.emails ?? []), raw] });
|
||||
setEmailDraft(d => ({ ...d, [groupId]: '' }));
|
||||
};
|
||||
|
||||
const removeEmail = (groupId: string, email: string) => {
|
||||
const g = groups.find(x => x.id === groupId);
|
||||
if (g) patchGroup(groupId, { emails: g.emails.filter(e => e !== email) });
|
||||
};
|
||||
|
||||
// ── Asignaciones ──
|
||||
const isAssigned = (scenario: string, groupId: string) => (assignments[scenario] ?? []).includes(groupId);
|
||||
|
||||
const toggleAssignment = (scenario: string, groupId: string) =>
|
||||
setAssignments(a => {
|
||||
const current = a[scenario] ?? [];
|
||||
const next = current.includes(groupId) ? current.filter(x => x !== groupId) : [...current, groupId];
|
||||
return { ...a, [scenario]: next };
|
||||
});
|
||||
|
||||
// ── Guardar ──
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
// Incorpora los emails escritos en el casillero pero aún no confirmados con Enter/+,
|
||||
// para que no se pierdan si el usuario pulsa Guardar directamente.
|
||||
const groupsToSave = groups.map(g => {
|
||||
const draft = (emailDraft[g.id] ?? '').trim();
|
||||
if (!draft || g.emails.includes(draft)) return g;
|
||||
return { ...g, emails: [...g.emails, draft] };
|
||||
});
|
||||
try {
|
||||
const res = await authFetch('/api/notification-config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ groups: groupsToSave, assignments }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||
}
|
||||
setEmailDraft({});
|
||||
toast.success('Configuración guardada');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error al guardar');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enviar prueba ──
|
||||
async function sendTest(scenario: string) {
|
||||
const testEmail = window.prompt(
|
||||
'Email de prueba (déjalo vacío para enviar a los destinatarios reales configurados):',
|
||||
'',
|
||||
);
|
||||
if (testEmail === null) return; // cancelado
|
||||
setTesting(scenario);
|
||||
try {
|
||||
const res = await authFetch('/api/notification-config/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scenario, testEmail: testEmail.trim() || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({} as { error?: string }));
|
||||
throw new Error(body?.error ?? `Error ${res.status}`);
|
||||
}
|
||||
toast.success(testEmail.trim() ? `Prueba enviada a ${testEmail.trim()}` : 'Prueba enviada a los destinatarios configurados');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error al enviar la prueba');
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-emerald-500/10 rounded-xl">
|
||||
<Bell className="w-6 h-6 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-white">Notificaciones</h1>
|
||||
<p className="text-sm text-slate-400">Quién recibe cada correo y con qué nivel de información.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm">
|
||||
<AlertCircle size={18} /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Grupos ── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs uppercase tracking-wider font-bold text-slate-500">Grupos de destinatarios</h2>
|
||||
{groups.map(g => (
|
||||
<div
|
||||
key={g.id}
|
||||
className={`rounded-2xl p-5 border ${
|
||||
g.level === 'reducida'
|
||||
? 'bg-amber-500/5 border-amber-500/30'
|
||||
: 'bg-emerald-500/5 border-emerald-500/20'
|
||||
} ${g.enabled === false ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<input
|
||||
value={g.name}
|
||||
onChange={e => patchGroup(g.id, { name: e.target.value })}
|
||||
className="flex-1 min-w-[160px] bg-slate-800/60 border border-slate-700 rounded-lg px-3 py-2 text-white font-bold focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
|
||||
/>
|
||||
<select
|
||||
value={g.level}
|
||||
onChange={e => patchGroup(g.id, { level: e.target.value as NotificationLevel })}
|
||||
className="bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
|
||||
>
|
||||
<option value="reducida">Reducida 🔒</option>
|
||||
<option value="completa">Completa</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={g.enabled !== false}
|
||||
onChange={e => patchGroup(g.id, { enabled: e.target.checked })}
|
||||
/>
|
||||
Activo
|
||||
</label>
|
||||
<button
|
||||
onClick={() => removeGroup(g.id)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
title="Borrar grupo"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{g.level === 'reducida' && (
|
||||
<p className="flex items-center gap-1.5 mt-2 text-xs text-amber-400/80">
|
||||
<Lock size={12} /> Solo recibe la versión reducida (sin datos del cliente).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2 items-center">
|
||||
{g.emails.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="flex items-center gap-1.5 bg-slate-800/70 border border-slate-700 rounded-full pl-3 pr-1.5 py-1 text-sm text-slate-200"
|
||||
>
|
||||
{email}
|
||||
<button onClick={() => removeEmail(g.id, email)} className="p-0.5 hover:bg-slate-700 rounded-full">
|
||||
<X size={13} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
value={emailDraft[g.id] ?? ''}
|
||||
onChange={e => setEmailDraft(d => ({ ...d, [g.id]: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addEmail(g.id); } }}
|
||||
placeholder="añadir email…"
|
||||
className="bg-slate-800/60 border border-slate-700 rounded-full px-3 py-1 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/40"
|
||||
/>
|
||||
<button onClick={() => addEmail(g.id)} className="p-1.5 text-emerald-400 hover:bg-emerald-500/10 rounded-full">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={addGroup}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-800/60 hover:bg-slate-800 border border-slate-700 rounded-xl text-slate-200 text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Nuevo grupo
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* ── Asignación ── */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xs uppercase tracking-wider font-bold text-slate-500">Qué grupo recibe cada escenario</h2>
|
||||
<div className="rounded-2xl border border-slate-700/50 divide-y divide-slate-800">
|
||||
{CONFIGURABLE_SCENARIOS.map(sc => (
|
||||
<div key={sc.scenario} className="p-4 flex flex-wrap items-center gap-3 justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-white text-sm">{sc.label}</p>
|
||||
{sc.dualVersion && (
|
||||
<p className="text-xs text-slate-500">Versión según el nivel del grupo (reducida/completa)</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groups.map(g => (
|
||||
<button
|
||||
key={g.id}
|
||||
onClick={() => toggleAssignment(sc.scenario, g.id)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
|
||||
isAssigned(sc.scenario, g.id)
|
||||
? g.level === 'reducida'
|
||||
? 'bg-amber-500/20 border-amber-500/40 text-amber-200'
|
||||
: 'bg-emerald-500/20 border-emerald-500/40 text-emerald-200'
|
||||
: 'bg-slate-800/40 border-slate-700 text-slate-500 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{g.name}{isAssigned(sc.scenario, g.id) ? ' ✓' : ''}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => sendTest(sc.scenario)}
|
||||
disabled={testing === sc.scenario}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-500/10 border border-blue-500/30 text-blue-300 hover:bg-blue-500/20 transition-colors disabled:opacity-50"
|
||||
title="Enviar email de prueba"
|
||||
>
|
||||
{testing === sc.scenario ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
|
||||
Prueba
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold transition-colors disabled:opacity-60"
|
||||
>
|
||||
{saving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
Guardar cambios
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Calendar, Settings, CalendarDays, Users } from 'lucide-react';
|
||||
import { Calendar, Settings, CalendarDays, Users, Bell } from 'lucide-react';
|
||||
import { PropertySelector } from './PropertySelector';
|
||||
import { usePropertyTheme } from '../hooks/usePropertyTheme';
|
||||
|
||||
@@ -16,6 +16,7 @@ export function Sidebar({ currentView, onNavigate, isViewer = false, isAdmin = f
|
||||
{ id: 'calendar', label: 'Mensual', icon: Calendar, requires: 'all' as const },
|
||||
{ id: 'yearly', label: 'Anual', icon: CalendarDays, requires: 'all' as const },
|
||||
{ id: 'users', label: 'Usuarios', icon: Users, requires: 'admin' as const },
|
||||
{ id: 'notifications', label: 'Notificaciones', icon: Bell, requires: 'admin' as const },
|
||||
{ id: 'settings', label: 'Ajustes', icon: Settings, requires: 'staff' as const },
|
||||
];
|
||||
const menuItems = allMenuItems.filter(item => {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
# Configuración de destinatarios de notificaciones — Diseño
|
||||
|
||||
**Fecha:** 2026-06-15
|
||||
**Estado:** Aprobado para implementación (pendiente revisión final del spec)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problema
|
||||
|
||||
Hoy, "quién recibe cada correo" se define con variables de entorno en Dokploy
|
||||
(`NOTIFICATION_EMAIL_TENERIFFA`, `NOTIFICATION_EMAIL_NATUR`,
|
||||
`NOTIFICATION_EMAIL_POOL_HEATING`). Esto es:
|
||||
|
||||
- **Rígido**: cualquier cambio exige editar Dokploy y redeplegar.
|
||||
- **Invisible**: el cliente no ve ni controla a quién llega cada cosa.
|
||||
- **Frágil/inseguro**: nada impide mandar información completa (datos del cliente) a
|
||||
un destinatario que solo debería recibir información reducida (Teneriffa, por
|
||||
acuerdo de privacidad).
|
||||
|
||||
## 2. Objetivo
|
||||
|
||||
Mover la configuración de destinatarios a la base de datos y gestionarla desde una
|
||||
**pantalla nueva dentro de la app** (solo admin). El motor de envío deja de leer las
|
||||
env vars y lee esta configuración.
|
||||
|
||||
**Regla dura de privacidad:** a un destinatario marcado como "reducida" es
|
||||
**imposible** enviarle la versión completa, por diseño del modelo.
|
||||
|
||||
### No-objetivos
|
||||
|
||||
- No se rediseña el formato visual de los emails: **las plantillas HTML actuales se
|
||||
conservan tal cual** (es justo lo que al cliente le gusta).
|
||||
- No se añade gestión de plantillas desde la UI (las plantillas siguen en disco).
|
||||
|
||||
## 3. Modelo de datos (Supabase, schema `natur_reservas`)
|
||||
|
||||
> **CRÍTICO:** todo el SQL se cualifica con `natur_reservas.` (instancia Supabase
|
||||
> compartida; `public` es otro proyecto). Ver CLAUDE.md §1.
|
||||
|
||||
### Tabla `natur_reservas.notification_groups`
|
||||
|
||||
Un grupo de destinatarios con un nivel de información fijo.
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | `gen_random_uuid()` |
|
||||
| `name` | text NOT NULL | Ej. "Teneriffa", "Naturcalabacera (Admin)", "Limpieza" |
|
||||
| `level` | text NOT NULL | `CHECK (level IN ('reducida','completa'))`, default `'completa'` |
|
||||
| `emails` | text[] NOT NULL | default `'{}'`; lista de correos del grupo |
|
||||
| `enabled` | boolean NOT NULL | default `true` |
|
||||
| `created_at` | timestamptz | default `now()` |
|
||||
| `updated_at` | timestamptz | default `now()` |
|
||||
|
||||
### Tabla `natur_reservas.notification_scenario_recipients`
|
||||
|
||||
Qué grupos recibe cada escenario (relación N:M escenario ↔ grupo).
|
||||
|
||||
| Columna | Tipo | Notas |
|
||||
|---|---|---|
|
||||
| `id` | uuid PK | `gen_random_uuid()` |
|
||||
| `scenario` | text NOT NULL | Igual que `event_type` (ver §5) |
|
||||
| `group_id` | uuid NOT NULL | `REFERENCES natur_reservas.notification_groups(id) ON DELETE CASCADE` |
|
||||
| `created_at` | timestamptz | default `now()` |
|
||||
| — | — | `UNIQUE (scenario, group_id)` |
|
||||
|
||||
### RLS
|
||||
|
||||
Solo `admin` gestiona ambas tablas (mismo patrón que `notification_events` en la
|
||||
migración 009: política `FOR ALL` con `get_user_role() = 'admin'`). El backend usa la
|
||||
`service_role` key, que bypasea RLS.
|
||||
|
||||
### Migración
|
||||
|
||||
Archivo nuevo `supabase/migrations/012_notification_config.sql` que crea las dos
|
||||
tablas, RLS y la **semilla** del §6.
|
||||
|
||||
## 4. Motor de envío (backend)
|
||||
|
||||
`apps/api/src/events/handler.ts` deja de leer `env.NOTIFICATION_EMAIL_*`. En su lugar,
|
||||
una función nueva resuelve destinatarios desde la BD por escenario:
|
||||
|
||||
```
|
||||
resolveRecipients(scenario) ->
|
||||
SELECT g.level, g.emails
|
||||
FROM natur_reservas.notification_scenario_recipients r
|
||||
JOIN natur_reservas.notification_groups g ON g.id = r.group_id
|
||||
WHERE r.scenario = $scenario AND g.enabled = true
|
||||
```
|
||||
|
||||
Reglas de uso:
|
||||
|
||||
- **Escenarios de reserva con doble versión** (`created`, `updated`, `cancelled`):
|
||||
- Emails de grupos `level = 'reducida'` → se envía la **plantilla reducida**
|
||||
(`renderTeneriffaMinimal`). Un envío con todos esos emails en `to`.
|
||||
- Emails de grupos `level = 'completa'` → se envía la **plantilla completa**
|
||||
(`natur-crud.html`). Un envío con todos esos emails en `to`.
|
||||
- Si una de las dos listas está vacía, ese envío simplemente no ocurre.
|
||||
- **Escenarios de plantilla única** (`invoice_second_notice`, `pool_heating_notice`):
|
||||
- Se juntan todos los emails de todos los grupos asignados (sin importar nivel,
|
||||
porque la plantilla es fija) → un envío con la plantilla del escenario.
|
||||
- Si un escenario no tiene grupos asignados, **no se envía nada** y se registra en log
|
||||
(`console.warn`) para diagnóstico.
|
||||
|
||||
El resto del flujo (render de plantilla, `sendEmail` → webhook n8n → Gmail) **no
|
||||
cambia**. El webhook y el formato siguen igual.
|
||||
|
||||
### Compatibilidad con env vars
|
||||
|
||||
Tras esta migración, `NOTIFICATION_EMAIL_TENERIFFA/_NATUR/_POOL_HEATING` dejan de
|
||||
usarse. Se eliminan del `required()` en `apps/api/src/config/env.ts` para que la app no
|
||||
dependa de ellas. (Se pueden retirar también de Dokploy, pero no es necesario.)
|
||||
|
||||
## 5. Escenarios (mapa de `event_type` → plantilla)
|
||||
|
||||
| Escenario (`event_type`) | Disparo | Plantilla(s) | Doble versión |
|
||||
|---|---|---|---|
|
||||
| `reservation.created` | Inmediato (al guardar) | reducida + completa | Sí |
|
||||
| `reservation.updated` | Inmediato (solo si cambian fechas/horas) | reducida + completa | Sí |
|
||||
| `reservation.cancelled` | Inmediato | reducida + completa | Sí |
|
||||
| `reservation.invoice_second_notice` | Programado 10 días antes | `invoice-10d` | No |
|
||||
| `reservation.pool_heating_notice` | Programado 48h antes | `pool-heating-48h` | No |
|
||||
|
||||
> El correo de check-in 24h (`reservation.reminder_24h`) ya fue eliminado del sistema
|
||||
> en una iteración previa.
|
||||
|
||||
## 6. Semilla inicial
|
||||
|
||||
**Grupos:**
|
||||
|
||||
| name | level | emails |
|
||||
|---|---|---|
|
||||
| Teneriffa | `reducida` | `uwe.dotschkis@teneriffa2000.de`, `j.reichstein@teneriffa2000.de` |
|
||||
| Naturcalabacera (Admin) | `completa` | `administracion@naturcalabacera.com` |
|
||||
| Limpieza | `completa` | `limpieza.naturcalabacera@gmail.com` *(placeholder editable)* |
|
||||
|
||||
**Asignación escenario → grupos:**
|
||||
|
||||
| Escenario | Teneriffa | Admin | Limpieza |
|
||||
|---|---|---|---|
|
||||
| `reservation.created` | ✅ | ✅ | — |
|
||||
| `reservation.updated` | ✅ | ✅ | — |
|
||||
| `reservation.cancelled` | ✅ | ✅ | — |
|
||||
| `reservation.invoice_second_notice` | — | ✅ | — |
|
||||
| `reservation.pool_heating_notice` | — | — | ✅ |
|
||||
|
||||
(El email de Limpieza y la posible copia de piscina para Admin se ajustan desde la
|
||||
pantalla una vez montada.)
|
||||
|
||||
## 7. Pantalla (frontend)
|
||||
|
||||
Nueva vista **"Notificaciones"**, accesible **solo para rol `admin`**.
|
||||
|
||||
- **Navegación:** añadir entrada en `Sidebar.tsx` y `MobileNavigation.tsx`
|
||||
(`requires: 'admin'`), y el render condicional en `App.tsx`
|
||||
(`currentView === 'notifications' && isAdmin`).
|
||||
- **Componente** `apps/web/src/components/NotificationSettings.tsx` con hook
|
||||
`useNotificationConfig` que lee/escribe las dos tablas vía el cliente Supabase
|
||||
(schema `natur_reservas`), siguiendo el patrón de `useReservations` /
|
||||
`UserManagement`.
|
||||
- **Bloque 1 — Grupos:** lista editable de grupos. Por grupo: nombre, selector de
|
||||
nivel (`reducida`/`completa`), chips de emails (añadir/quitar), activar/desactivar,
|
||||
borrar. Botón "+ Nuevo grupo".
|
||||
- **Bloque 2 — Asignación:** por cada escenario, chips de los grupos; click para
|
||||
activar/desactivar qué grupos lo reciben.
|
||||
- **Guardar:** persiste cambios (upsert de grupos y de asignaciones).
|
||||
- **Estética:** Tailwind, modo oscuro, paleta de la app (emerald/amber/slate),
|
||||
iconos `lucide-react`, toasts `sonner`. Coherente con `SettingsPage` /
|
||||
`UserManagement`.
|
||||
|
||||
## 8. Botón "Enviar prueba"
|
||||
|
||||
Por cada escenario, un botón que dispara ese correo **con datos ficticios** sin crear
|
||||
reservas reales.
|
||||
|
||||
- **Endpoint** nuevo `POST /api/notifications/test-scenario` (protegido, admin):
|
||||
`{ scenario, testEmail? }`.
|
||||
- Construye una `Reservation` ficticia coherente con el escenario y llama al mismo
|
||||
`handleNotificationEvent`, de modo que la prueba usa **exactamente** la misma lógica
|
||||
y plantillas que el envío real.
|
||||
- Destinatario: si `testEmail` viene, se envía solo a ese; si no, a los destinatarios
|
||||
configurados del escenario (prueba realista de entrega).
|
||||
- El asunto/encabezado de prueba se marca visualmente como tal (p. ej. prefijo
|
||||
`[PRUEBA]`) para no confundir con un aviso real.
|
||||
|
||||
## 9. Riesgo conocido a verificar en implementación
|
||||
|
||||
Esta pantalla resuelve **a quién** y **con qué formato** llega cada correo, pero **no**
|
||||
garantiza por sí sola que los correos de reserva (CRUD) se disparen en producción.
|
||||
|
||||
Patrón observado durante el diagnóstico: los correos de **jobs** (segunda factura;
|
||||
prueba manual de piscina) llegan, mientras que los **CRUD disparados desde el
|
||||
navegador** (`App.tsx` → `POST /api/notifications/reservation-event`) estaban en duda.
|
||||
El `notifyApi` del frontend es "fire-and-forget" con errores silenciados
|
||||
(`App.tsx:236-239`) y depende de `VITE_API_URL` (build) y de `WEB_ORIGIN`/CORS
|
||||
(`index.ts:11-14`).
|
||||
|
||||
**Tarea explícita del plan:** verificar que el frontend de producción alcanza el
|
||||
backend (valores reales de `VITE_API_URL` y `WEB_ORIGIN`, ver una llamada a
|
||||
`reservation-event` en DevTools/Network). Si esa vía está rota, ninguna configuración
|
||||
de destinatarios hará que salgan los correos de reserva. Considerar, si procede, dejar
|
||||
de silenciar el error en `notifyApi` (al menos un `console.error` / toast) para que el
|
||||
fallo sea visible.
|
||||
|
||||
## 10. Pruebas
|
||||
|
||||
- **Backend:** test unitario de `resolveRecipients` (separación por nivel; escenario
|
||||
sin grupos → sin envío; plantilla correcta por nivel).
|
||||
- **Regla de privacidad:** test que verifica que un grupo `reducida` nunca recibe la
|
||||
plantilla completa, en ninguno de los escenarios de doble versión.
|
||||
- **Manual:** botón "Enviar prueba" de cada escenario contra el webhook real,
|
||||
comprobando entrega y formato (continuidad del método que veníamos usando).
|
||||
@@ -2,9 +2,10 @@
|
||||
export type { Reservation, NewReservation, ReservationOrigin, Property, PricingSnapshot, WebhookPayload } from './types/reservation.js';
|
||||
export type { UserRole, UserProfile } from './types/user.js';
|
||||
export type { PricingInput, PricingResult } from './types/pricing.js';
|
||||
export type { NotificationEventType, NotificationStatus, NotificationEvent } from './types/notification.js';
|
||||
export type { NotificationEventType, NotificationStatus, NotificationEvent, NotificationLevel, NotificationGroup, NotificationConfig, NotificationScenarioMeta } from './types/notification.js';
|
||||
|
||||
// Constants
|
||||
export { CONFIGURABLE_SCENARIOS } from './types/notification.js';
|
||||
export { PROPERTY_CONFIG, PROPERTIES, DEFAULT_IGIC_RATE, getExtraPersonRate } from './constants/properties.js';
|
||||
export { ORIGIN_CONFIG } from './constants/origins.js';
|
||||
|
||||
|
||||
@@ -3,12 +3,50 @@ export type NotificationEventType =
|
||||
| 'reservation.created'
|
||||
| 'reservation.updated'
|
||||
| 'reservation.cancelled'
|
||||
| 'reservation.reminder_24h'
|
||||
| 'reservation.invoice_second_notice'
|
||||
| 'reservation.pool_heating_notice';
|
||||
|
||||
export type NotificationStatus = 'pending' | 'sent' | 'failed';
|
||||
|
||||
// ── Configuración de destinatarios (gestionada desde la app) ──
|
||||
|
||||
// Nivel de información que un grupo puede recibir.
|
||||
// 'reducida' nunca recibe datos completos del cliente (regla de privacidad).
|
||||
export type NotificationLevel = 'reducida' | 'completa';
|
||||
|
||||
export interface NotificationGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
level: NotificationLevel;
|
||||
emails: string[];
|
||||
enabled: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// Estado completo de la configuración: grupos + qué grupos recibe cada escenario.
|
||||
// assignments: { [event_type]: groupId[] }
|
||||
export interface NotificationConfig {
|
||||
groups: NotificationGroup[];
|
||||
assignments: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// Escenarios configurables desde la pantalla, con etiqueta y si tienen doble versión.
|
||||
export interface NotificationScenarioMeta {
|
||||
scenario: NotificationEventType;
|
||||
label: string;
|
||||
/** true = tiene versión reducida y completa (escenarios CRUD de reserva) */
|
||||
dualVersion: boolean;
|
||||
}
|
||||
|
||||
export const CONFIGURABLE_SCENARIOS: NotificationScenarioMeta[] = [
|
||||
{ scenario: 'reservation.created', label: 'Nueva reserva', dualVersion: true },
|
||||
{ scenario: 'reservation.updated', label: 'Reserva modificada (fechas)', dualVersion: true },
|
||||
{ scenario: 'reservation.cancelled', label: 'Reserva cancelada', dualVersion: true },
|
||||
{ scenario: 'reservation.invoice_second_notice', label: 'Segunda factura (10 días antes)', dualVersion: false },
|
||||
{ scenario: 'reservation.pool_heating_notice', label: 'Calefacción de piscina (48h antes)', dualVersion: false },
|
||||
];
|
||||
|
||||
export interface NotificationEvent {
|
||||
id: string;
|
||||
reservation_id: string;
|
||||
|
||||
87
supabase/migrations/012_notification_config.sql
Normal file
87
supabase/migrations/012_notification_config.sql
Normal file
@@ -0,0 +1,87 @@
|
||||
-- Migración 012: Configuración de destinatarios de notificaciones
|
||||
--
|
||||
-- Mueve "quién recibe cada correo" desde variables de entorno (Dokploy) a la BD,
|
||||
-- gestionable desde la app. Dos tablas:
|
||||
-- notification_groups — grupos de destinatarios con un nivel de info fijo
|
||||
-- notification_scenario_recipients — qué grupos recibe cada escenario (event_type)
|
||||
--
|
||||
-- IMPORTANTE: schema natur_reservas (la instancia Supabase es compartida; public es
|
||||
-- otro proyecto). Ver CLAUDE.md §1.
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- Grupos de destinatarios
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE TABLE natur_reservas.notification_groups (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL DEFAULT 'completa'
|
||||
CHECK (level IN ('reducida', 'completa')),
|
||||
emails TEXT[] NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE natur_reservas.notification_groups IS
|
||||
'Grupos de destinatarios de notificaciones. level=reducida solo puede recibir la versión reducida de los correos (regla de privacidad).';
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- Asignación escenario → grupos (N:M)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
CREATE TABLE natur_reservas.notification_scenario_recipients (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
scenario TEXT NOT NULL, -- igual que notification_events.event_type
|
||||
group_id UUID NOT NULL
|
||||
REFERENCES natur_reservas.notification_groups(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_scenario_group UNIQUE (scenario, group_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scenario_recipients_scenario
|
||||
ON natur_reservas.notification_scenario_recipients(scenario);
|
||||
|
||||
COMMENT ON TABLE natur_reservas.notification_scenario_recipients IS
|
||||
'Qué grupos reciben cada escenario (event_type). El motor de envío en apps/api lo consulta con service_role.';
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- RLS: el acceso del frontend va por el API (service_role bypasea RLS).
|
||||
-- Bloqueamos acceso directo de anon/authenticated; el API valida admin.
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
ALTER TABLE natur_reservas.notification_groups ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE natur_reservas.notification_scenario_recipients ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- El API accede con service_role: bypasea RLS, pero necesita el GRANT de tabla
|
||||
-- (en schemas custom no se concede automáticamente, a diferencia de public).
|
||||
GRANT USAGE ON SCHEMA natur_reservas TO service_role;
|
||||
GRANT ALL ON natur_reservas.notification_groups TO service_role;
|
||||
GRANT ALL ON natur_reservas.notification_scenario_recipients TO service_role;
|
||||
|
||||
REVOKE ALL ON natur_reservas.notification_groups FROM anon;
|
||||
REVOKE ALL ON natur_reservas.notification_scenario_recipients FROM anon;
|
||||
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
-- Semilla inicial (editable después desde la pantalla)
|
||||
-- ────────────────────────────────────────────────────────────
|
||||
WITH nuevos AS (
|
||||
INSERT INTO natur_reservas.notification_groups (name, level, emails) VALUES
|
||||
('Teneriffa', 'reducida',
|
||||
ARRAY['uwe.dotschkis@teneriffa2000.de', 'j.reichstein@teneriffa2000.de']),
|
||||
('Naturcalabacera (Admin)', 'completa',
|
||||
ARRAY['administracion@naturcalabacera.com']),
|
||||
('Limpieza', 'completa',
|
||||
ARRAY['limpieza.naturcalabacera@gmail.com']) -- placeholder editable
|
||||
RETURNING id, name
|
||||
)
|
||||
INSERT INTO natur_reservas.notification_scenario_recipients (scenario, group_id)
|
||||
SELECT s.scenario, n.id
|
||||
FROM nuevos n
|
||||
JOIN (VALUES
|
||||
('Teneriffa', 'reservation.created'),
|
||||
('Teneriffa', 'reservation.updated'),
|
||||
('Teneriffa', 'reservation.cancelled'),
|
||||
('Naturcalabacera (Admin)', 'reservation.created'),
|
||||
('Naturcalabacera (Admin)', 'reservation.updated'),
|
||||
('Naturcalabacera (Admin)', 'reservation.cancelled'),
|
||||
('Naturcalabacera (Admin)', 'reservation.invoice_second_notice'),
|
||||
('Limpieza', 'reservation.pool_heating_notice')
|
||||
) AS s(group_name, scenario) ON s.group_name = n.name;
|
||||
Reference in New Issue
Block a user