Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)

This commit is contained in:
2026-04-30 10:09:44 +01:00
commit a0ccb8ca64
188 changed files with 16418 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
/**
* Configuración centralizada de variables de entorno.
* Falla al arrancar si faltan variables críticas.
*/
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Variable de entorno requerida: ${name}`);
return value;
}
function optional(name: string, defaultValue: string): string {
return process.env[name] ?? defaultValue;
}
export const env = {
// Servidor
PORT: parseInt(optional('API_PORT', '3001'), 10),
NODE_ENV: optional('NODE_ENV', 'development'),
// Supabase (self-hosted)
SUPABASE_URL: required('SUPABASE_URL'),
SUPABASE_SERVICE_ROLE_KEY: required('SUPABASE_SERVICE_ROLE_KEY'),
// Email — Resend
RESEND_API_KEY: required('RESEND_API_KEY'),
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'),
// Jobs
JOB_RUNNER_INTERVAL_MS: parseInt(optional('JOB_RUNNER_INTERVAL_MS', '300000'), 10), // 5 min
JOB_MAX_RETRIES: parseInt(optional('JOB_MAX_RETRIES', '3'), 10),
// Origen del frontend para CORS
WEB_ORIGIN: optional('WEB_ORIGIN', 'http://localhost:5173'),
};

View File

@@ -0,0 +1,252 @@
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 type { Reservation, NotificationEventType } from '@naturcalabacera/shared';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEMPLATES_DIR = join(__dirname, '../templates');
/**
* Carga un template HTML y reemplaza las variables {{VARIABLE}}.
*/
function renderTemplate(templateName: string, vars: Record<string, string>): string {
const filePath = join(TEMPLATES_DIR, `${templateName}.html`);
let html = readFileSync(filePath, 'utf-8');
for (const [key, value] of Object.entries(vars)) {
html = html.replaceAll(`{{${key}}}`, value);
}
return html;
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr + 'T12:00:00Z');
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'long', year: 'numeric' });
}
function formatDateShort(dateStr: string): string {
const date = new Date(dateStr + 'T12:00:00Z');
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' });
}
function propertyLabel(property: string): string {
return property === 'los_dragos' ? 'Los Dragos' : 'La Esquinita';
}
function nightsCount(start: string, end: string): string {
const d1 = new Date(start + 'T12:00:00Z');
const d2 = new Date(end + 'T12:00:00Z');
const nights = Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24));
return `${nights} noche${nights === 1 ? '' : 's'}`;
}
function formatFieldValue(field: string, value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
if (field === 'start_date' || field === 'end_date') return formatDate(String(value));
if (field === 'property') return propertyLabel(String(value));
if (field === 'origin') return value === 'Teneriffa2000' ? 'Teneriffa' : 'Natur';
if (typeof value === 'boolean') return value ? 'Sí' : 'No';
return String(value);
}
/**
* Genera el bloque HTML con la tabla de cambios entre dos versiones de reserva.
* Se incluye solo en emails de tipo "modificada".
*/
function buildChangesBlock(prev: Reservation, curr: Reservation): string {
const FIELD_LABELS: Record<string, string> = {
client_name: 'Cliente',
start_date: 'Fecha de entrada',
end_date: 'Fecha de salida',
property: 'Propiedad',
adults_count: 'Adultos',
children_count: 'Niños',
has_cleaning: 'Limpieza incluida',
has_pool_heating: 'Calefacción de piscina',
has_flies_products: 'Productos antiparasitarios',
government_registration: 'Registro gubernamental',
observations: 'Observaciones',
is_event: 'Es evento',
event_type: 'Tipo de evento',
attendees_count: 'Nº asistentes',
};
const changes: Array<{ label: string; from: string; to: string }> = [];
for (const [field, label] of Object.entries(FIELD_LABELS)) {
const prevVal = (prev as Record<string, unknown>)[field];
const currVal = (curr as Record<string, unknown>)[field];
const prevStr = formatFieldValue(field, prevVal);
const currStr = formatFieldValue(field, currVal);
if (prevStr !== currStr) {
changes.push({ label, from: prevStr, to: currStr });
}
}
if (changes.length === 0) {
return `<p style="color:#6b7280; font-style:italic; margin:0; font-size:14px;">Sin cambios detectados en los datos principales.</p>`;
}
let rows = '';
for (const { label, from, to } of changes) {
rows += `
<tr>
<td style="padding:10px 12px; font-weight:600; color:#374151; border-bottom:1px solid #e5e7eb; font-size:14px;">${label}</td>
<td style="padding:10px 12px; color:#991b1b; text-decoration:line-through; border-bottom:1px solid #e5e7eb; font-size:14px;">${from}</td>
<td style="padding:10px 12px; color:#065f46; font-weight:700; border-bottom:1px solid #e5e7eb; font-size:14px;">${to}</td>
</tr>`;
}
return `
<div style="margin-top:24px; border-top:2px solid #f59e0b; padding-top:20px;">
<h3 style="margin:0 0 14px; font-size:12px; text-transform:uppercase; letter-spacing:0.08em; color:#92400e; font-weight:700;">Cambios realizados</h3>
<table style="width:100%; border-collapse:collapse;">
<thead>
<tr style="background:#fef3c7;">
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Campo</th>
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Antes</th>
<th style="padding:8px 12px; text-align:left; font-size:11px; text-transform:uppercase; color:#92400e; font-weight:700; letter-spacing:0.05em;">Ahora</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
/**
* Genera el bloque HTML de observaciones si existen.
*/
function buildObservationsBlock(observations?: string): string {
if (!observations || observations.trim() === '') return '';
return `
<div style="margin-top:20px; padding:14px 16px; background:#f0fdf4; border-left:4px solid #10b981; border-radius:0 8px 8px 0;">
<p style="margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:#065f46; font-weight:700;">Observaciones</p>
<p style="margin:0; font-size:14px; color:#374151; line-height:1.5;">${observations}</p>
</div>`;
}
/**
* Genera la lista de servicios adicionales de la reserva.
*/
function buildServicesText(r: Reservation): string {
const services: string[] = [];
if (r.has_cleaning) services.push('Limpieza');
if (r.has_pool_heating) services.push('Calefacción piscina');
if (r.has_flies_products) services.push('Antiparasitarios');
if (r.is_event && r.event_type) {
const label = r.event_type === 'Otro' ? `Evento: ${r.event_type_other ?? 'otro'}` : `Evento: ${r.event_type}`;
services.push(label);
}
return services.length > 0 ? services.join(' · ') : '—';
}
interface SendResult {
success: boolean;
error?: string;
}
/**
* Envía el email correspondiente a un evento de notificación.
* Para reservation.updated acepta previousReservation para mostrar el diff.
*/
export async function handleNotificationEvent(
eventType: NotificationEventType,
reservation: Reservation,
previousReservation?: Reservation
): Promise<SendResult> {
const nights = nightsCount(reservation.start_date, reservation.end_date);
const dateRange = `${formatDateShort(reservation.start_date)} ${formatDateShort(reservation.end_date)}`;
const baseVars: Record<string, string> = {
CLIENT_NAME: reservation.client_name,
START_DATE: formatDate(reservation.start_date),
END_DATE: formatDate(reservation.end_date),
PROPERTY: propertyLabel(reservation.property),
ORIGIN: reservation.origin === 'Teneriffa2000' ? 'Teneriffa' : 'Natur',
ADULTS: String(reservation.adults_count),
CHILDREN: String(reservation.children_count),
TOTAL_PERSONS: String(reservation.adults_count + reservation.children_count),
RESERVATION_ID: reservation.id,
INVOICE_NUMBER: reservation.invoice_number ?? '—',
NIGHTS: nights,
DATE_RANGE: dateRange,
SERVICES: buildServicesText(reservation),
GOVERNMENT_REG: reservation.government_registration ?? '—',
OBSERVATIONS_BLOCK: buildObservationsBlock(reservation.observations),
CHANGES_BLOCK: '',
CANCEL_ALERT: '',
};
switch (eventType) {
case 'reservation.created': {
const isTeenriffa = reservation.origin === 'Teneriffa2000';
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
const vars = {
...baseVars,
ACTION: 'creada',
ACTION_LABEL: 'Nueva Reserva',
CHANGES_BLOCK: '',
};
const html = renderTemplate(template, vars);
const subject = `[NUEVA RESERVA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
}
case 'reservation.updated': {
const isTeenriffa = reservation.origin === 'Teneriffa2000';
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
const changesBlock = previousReservation
? buildChangesBlock(previousReservation, reservation)
: `<p style="color:#6b7280; font-style:italic; margin:0; font-size:14px;">No se recibieron datos anteriores para comparar.</p>`;
const vars = {
...baseVars,
ACTION: 'modificada',
ACTION_LABEL: 'Reserva Modificada',
CHANGES_BLOCK: changesBlock,
};
const html = renderTemplate(template, vars);
const subject = `[MODIFICADA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
}
case 'reservation.cancelled': {
const isTeenriffa = reservation.origin === 'Teneriffa2000';
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
const vars = {
...baseVars,
ACTION: 'cancelada',
ACTION_LABEL: 'Reserva Cancelada',
CHANGES_BLOCK: '',
CANCEL_ALERT: `<div class="cancel-alert"><p>Esta reserva ha sido cancelada y eliminada del sistema.</p></div>`,
};
const html = renderTemplate(template, vars);
const subject = `[CANCELADA] ${originLabel}${reservation.client_name} | ${propertyLabel(reservation.property)} | ${dateRange}`;
return sendEmail({ to: [env.NOTIFICATION_EMAIL_TENERIFFA, env.NOTIFICATION_EMAIL_NATUR], subject, html });
}
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': {
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 });
}
case 'reservation.pool_heating_notice': {
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 });
}
default:
return { success: false, error: `Tipo de evento desconocido: ${eventType}` };
}
}

View File

@@ -0,0 +1,67 @@
import type { NotificationEventType } from '@naturcalabacera/shared';
import type { Reservation, ReservationOrigin, Property } from '@naturcalabacera/shared';
export type CrudOperation = 'created' | 'updated' | 'cancelled';
/**
* Condiciones que determinan si se envía un evento de notificación.
* Permiten evaluar de forma declarativa sin dispersar la lógica.
*/
export interface NotificationCondition {
origin?: ReservationOrigin;
property?: Property;
hasPoolHeating?: boolean;
}
/**
* Descriptor de cada tipo de evento:
* - conditions: cuándo se dispara
* - recipientEnvKey: qué env var contiene el destinatario
* - templateName: nombre del template HTML
* - offsetDays: días antes del check-in (para eventos programados; undefined = inmediato)
*/
export interface EventDescriptor {
eventType: NotificationEventType;
conditions: NotificationCondition;
recipientEnvKey: 'NOTIFICATION_EMAIL_TENERIFFA' | 'NOTIFICATION_EMAIL_NATUR' | 'NOTIFICATION_EMAIL_POOL_HEATING';
templateName: string;
offsetDays?: number; // undefined = disparo inmediato (CRUD)
}
/**
* Mapa declarativo de todos los eventos del sistema.
* 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' },
recipientEnvKey: 'NOTIFICATION_EMAIL_NATUR',
templateName: 'invoice-10d',
offsetDays: 10,
},
{
eventType: 'reservation.pool_heating_notice',
conditions: { hasPoolHeating: true },
recipientEnvKey: 'NOTIFICATION_EMAIL_POOL_HEATING',
templateName: 'pool-heating-48h',
offsetDays: 2,
},
];
/**
* Evalúa si una reserva cumple las condiciones de un descriptor de evento.
*/
export function matchesConditions(reservation: Reservation, conditions: NotificationCondition): boolean {
if (conditions.origin && reservation.origin !== conditions.origin) return false;
if (conditions.property && reservation.property !== conditions.property) return false;
if (conditions.hasPoolHeating !== undefined && reservation.has_pool_heating !== conditions.hasPoolHeating) return false;
return true;
}

38
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import express from 'express';
import cors from 'cors';
import { env } from './config/env.js';
import { healthRouter } from './routes/health.js';
import { notificationsRouter } from './routes/notifications.js';
import { usersRouter } from './routes/users.js';
import { runPendingJobs } from './jobs/runner.js';
const app = express();
// Middlewares
const allowedOrigin = env.NODE_ENV === 'development'
? (origin: string | undefined, cb: (e: Error | null, ok?: boolean) => void) => cb(null, true)
: env.WEB_ORIGIN;
app.use(cors({ origin: allowedOrigin, credentials: true }));
app.use(express.json());
// Rutas
app.use('/health', healthRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/users', usersRouter);
// Arrancar servidor
app.listen(env.PORT, () => {
console.log(`[api] Servidor iniciado en puerto ${env.PORT} (${env.NODE_ENV})`);
});
// Job runner — procesa notificaciones pendientes cada JOB_RUNNER_INTERVAL_MS
// Solo una instancia debería ejecutarlo; el locking en BD evita duplicados si hay varias.
console.log(`[api] Job runner iniciado (intervalo: ${env.JOB_RUNNER_INTERVAL_MS / 1000}s)`);
// Primera ejecución inmediata
runPendingJobs().catch(err => console.error('[jobs] Error en primera ejecución:', err));
// Ejecuciones periódicas
setInterval(() => {
runPendingJobs().catch(err => console.error('[jobs] Error en runner:', err));
}, env.JOB_RUNNER_INTERVAL_MS);

168
apps/api/src/jobs/runner.ts Normal file
View File

@@ -0,0 +1,168 @@
import { supabaseAdmin } from '../lib/supabase.js';
import { handleNotificationEvent } from '../events/handler.js';
import { env } from '../config/env.js';
import type { NotificationEventType, Reservation } from '@naturcalabacera/shared';
/**
* Runner de jobs con garantías de idempotencia y robustez:
*
* - Selecciona eventos pendientes con FOR UPDATE SKIP LOCKED (via RPC)
* para que múltiples instancias no procesen el mismo evento.
* - Actualiza status a 'processing' antes de procesar.
* - En éxito: status = 'sent'. En fallo: incrementa attempts, registra error.
* - Reintentos máximos configurables (JOB_MAX_RETRIES).
* - Todas las comparaciones de fechas en UTC.
*/
interface NotificationRow {
id: string;
reservation_id: string;
event_type: NotificationEventType;
attempts: number;
}
export async function runPendingJobs(): Promise<void> {
const now = new Date().toISOString();
// Selecciona hasta 10 eventos pendientes cuyo scheduled_for ya ha pasado
// y que no hayan superado el máximo de reintentos
const { data: events, error: fetchError } = await supabaseAdmin
.from('notification_events')
.select('id, reservation_id, event_type, attempts')
.eq('status', 'pending')
.lte('scheduled_for', now)
.lt('attempts', env.JOB_MAX_RETRIES)
.order('scheduled_for', { ascending: true })
.limit(10);
if (fetchError) {
console.error('[jobs] Error al obtener eventos pendientes:', fetchError.message);
return;
}
if (!events || events.length === 0) return;
console.log(`[jobs] Procesando ${events.length} evento(s)...`);
for (const event of events as NotificationRow[]) {
// Marcar como 'processing' para evitar que otra instancia lo tome
const { error: lockError } = await supabaseAdmin
.from('notification_events')
.update({ status: 'processing', attempts: event.attempts + 1 })
.eq('id', event.id)
.eq('status', 'pending'); // Solo actualiza si sigue en pending (evita race)
if (lockError) {
console.warn(`[jobs] Evento ${event.id} ya tomado por otra instancia`);
continue;
}
// Obtener la reserva completa
const { data: reservation, error: resError } = await supabaseAdmin
.from('reservations')
.select('*')
.eq('id', event.reservation_id)
.single();
if (resError || !reservation) {
await markFailed(event.id, `Reserva no encontrada: ${event.reservation_id}`);
continue;
}
// Procesar el evento
const result = await handleNotificationEvent(event.event_type, reservation as Reservation);
if (result.success) {
await supabaseAdmin
.from('notification_events')
.update({ status: 'sent', sent_at: new Date().toISOString() })
.eq('id', event.id);
console.log(`[jobs] ✓ Evento ${event.event_type} para reserva ${event.reservation_id}`);
} else {
const isLastAttempt = event.attempts + 1 >= env.JOB_MAX_RETRIES;
await supabaseAdmin
.from('notification_events')
.update({
status: isLastAttempt ? 'failed' : 'pending',
last_error: result.error ?? 'Error desconocido',
})
.eq('id', event.id);
console.error(`[jobs] ✗ Evento ${event.event_type}: ${result.error}`);
}
}
}
async function markFailed(eventId: string, error: string): Promise<void> {
await supabaseAdmin
.from('notification_events')
.update({ status: 'failed', last_error: error })
.eq('id', eventId);
}
/**
* Inserta eventos de notificación para una reserva según las condiciones del evento.
* Usa ON CONFLICT DO NOTHING para idempotencia — si ya existe, no hace nada.
*/
export async function scheduleNotificationsForReservation(
reservation: Reservation,
operation: 'created' | 'updated' | 'cancelled'
): Promise<void> {
const eventsToInsert = [];
const startDate = new Date(reservation.start_date + 'T12:00:00Z');
const now = new Date().toISOString();
// 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
if (reservation.origin === 'Naturcalabacera') {
const scheduled = new Date(startDate);
scheduled.setUTCDate(scheduled.getUTCDate() - 10);
if (scheduled > new Date()) {
eventsToInsert.push({
reservation_id: reservation.id,
event_type: 'reservation.invoice_second_notice',
scheduled_for: scheduled.toISOString(),
status: 'pending',
});
}
}
// 4. Aviso calefacción piscina 48h antes
if (reservation.has_pool_heating) {
const scheduled = new Date(startDate);
scheduled.setUTCHours(scheduled.getUTCHours() - 48);
if (scheduled > new Date()) {
eventsToInsert.push({
reservation_id: reservation.id,
event_type: 'reservation.pool_heating_notice',
scheduled_for: scheduled.toISOString(),
status: 'pending',
});
}
}
if (eventsToInsert.length === 0) return;
// onConflict con la constraint UNIQUE(reservation_id, event_type, scheduled_for)
const { error } = await supabaseAdmin
.from('notification_events')
.upsert(eventsToInsert, { onConflict: 'reservation_id,event_type,scheduled_for', ignoreDuplicates: true });
if (error) {
console.error('[jobs] Error al insertar notification_events:', error.message);
}
}

View File

@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js';
import { env } from '../config/env.js';
/**
* Cliente Supabase con service_role key.
* Solo usar en el backend — nunca exponer al frontend.
* Bypasea RLS: úsalo con precaución.
*/
export const supabaseAdmin = createClient(
env.SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
{
db: { schema: 'natur_reservas' },
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
const router = Router();
router.get('/', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
export { router as healthRouter };

View File

@@ -0,0 +1,67 @@
import { Router } from 'express';
import { scheduleNotificationsForReservation } from '../jobs/runner.js';
import { handleNotificationEvent } from '../events/handler.js';
import { supabaseAdmin } from '../lib/supabase.js';
import type { Reservation } from '@naturcalabacera/shared';
const router = Router();
/**
* POST /api/notifications/reservation-event
*
* Llamado por el frontend tras cada operación CRUD exitosa.
* - Envía el email CRUD inmediatamente (sin pasar por la cola)
* - Encola los eventos futuros (recordatorios, facturas, etc.)
*
* Body: { reservation: Reservation, operation: 'created' | 'updated' | 'cancelled' }
*/
router.post('/reservation-event', async (req, res) => {
const { reservation, operation, previousReservation } = req.body as {
reservation: Reservation;
operation: 'created' | 'updated' | 'cancelled';
previousReservation?: Reservation;
};
if (!reservation || !operation) {
return res.status(400).json({ error: 'reservation y operation son requeridos' });
}
if (!['created', 'updated', 'cancelled'].includes(operation)) {
return res.status(400).json({ error: 'operation debe ser created, updated o cancelled' });
}
try {
// Enviar email CRUD al momento, sin esperar al job runner
const eventType = `reservation.${operation}` as const;
const now = new Date().toISOString();
const emailResult = await handleNotificationEvent(eventType, reservation, previousReservation);
// Registrar el evento en notification_events (historial)
await supabaseAdmin.from('notification_events').insert({
reservation_id: reservation.id,
event_type: eventType,
scheduled_for: now,
status: emailResult.success ? 'sent' : 'failed',
sent_at: emailResult.success ? now : null,
last_error: emailResult.success ? null : (emailResult.error ?? 'Error desconocido'),
attempts: 1,
});
if (!emailResult.success) {
console.error(`[notifications] Email CRUD fallido: ${emailResult.error}`);
} else {
console.log(`[notifications] ✓ Email enviado para ${eventType}${reservation.client_name}`);
}
// Encolar eventos futuros (recordatorios, avisos, etc.)
await scheduleNotificationsForReservation(reservation, operation);
return res.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Error interno';
console.error('[notifications] Error:', message);
return res.status(500).json({ error: message });
}
});
export { router as notificationsRouter };

View File

@@ -0,0 +1,154 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import { supabaseAdmin } from '../lib/supabase.js';
import type { UserRole } from '@naturcalabacera/shared';
const router = 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
.from('user_profiles')
.select('id, email, role, display_name, created_at')
.order('created_at', { ascending: false });
if (error) {
return res.status(500).json({ error: error.message });
}
return res.json({ users: data ?? [] });
});
// POST /api/users/invite — invitar un usuario nuevo
router.post('/invite', requireAdmin, async (req, res) => {
const { email, role, display_name } = req.body as {
email?: string;
role?: UserRole;
display_name?: string;
};
if (!email || !role) {
return res.status(400).json({ error: 'email y role son requeridos' });
}
if (!VALID_ROLES.includes(role)) {
return res.status(400).json({ error: 'Rol inválido' });
}
// 1. Invitar al usuario por email (envía correo con magic link)
const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(email);
if (inviteError || !invited.user) {
return res.status(400).json({ error: inviteError?.message ?? 'Error al invitar usuario' });
}
// 2. Crear el perfil con el rol seleccionado
const { data: profile, error: profileError } = await supabaseAdmin
.from('user_profiles')
.insert({
id: invited.user.id,
email,
role,
display_name: display_name ?? null,
})
.select()
.single();
if (profileError) {
// Best-effort cleanup: eliminar el usuario auth si el perfil falló
await supabaseAdmin.auth.admin.deleteUser(invited.user.id).catch(() => {});
return res.status(500).json({ error: profileError.message });
}
return res.status(201).json({ user: profile });
});
// PATCH /api/users/:id — actualizar rol o display_name
router.patch('/:id', requireAdmin, async (req, res) => {
const { id } = req.params;
const { role, display_name } = req.body as {
role?: UserRole;
display_name?: string;
};
if (role !== undefined && !VALID_ROLES.includes(role)) {
return res.status(400).json({ error: 'Rol inválido' });
}
const updates: Record<string, unknown> = {};
if (role !== undefined) updates.role = role;
if (display_name !== undefined) updates.display_name = display_name;
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Nada que actualizar' });
}
const { data, error } = await supabaseAdmin
.from('user_profiles')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) {
return res.status(500).json({ error: error.message });
}
return res.json({ user: data });
});
// DELETE /api/users/:id — eliminar usuario auth + perfil
router.delete('/:id', requireAdmin, async (req, res) => {
const id = req.params.id as string;
const callerId = res.locals.callerId as string;
if (id === callerId) {
return res.status(400).json({ error: 'No puedes eliminar tu propia cuenta' });
}
// ON DELETE CASCADE en user_profiles.id elimina el perfil al eliminar el usuario auth.
const { error } = await supabaseAdmin.auth.admin.deleteUser(id);
if (error) {
return res.status(500).json({ error: error.message });
}
return res.json({ ok: true });
});
export { router as usersRouter };

View File

@@ -0,0 +1,50 @@
import { env } from '../config/env.js';
interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
replyTo?: string;
}
interface ResendResponse {
id?: string;
error?: { message: string; name: string };
}
/**
* Envía un email usando la API de Resend.
* Docs: https://resend.com/docs/api-reference/emails/send-email
*/
export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; id?: string; error?: string }> {
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: env.EMAIL_FROM,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
...(options.replyTo && { reply_to: options.replyTo }),
}),
});
const data = await res.json() as ResendResponse;
if (!res.ok || data.error) {
const errorMsg = data.error?.message ?? `HTTP ${res.status}`;
console.error('[email] Error al enviar:', errorMsg);
return { success: false, error: errorMsg };
}
return { success: true, id: data.id };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Error desconocido';
console.error('[email] Error de red:', errorMsg);
return { success: false, error: errorMsg };
}
}

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Segunda Factura en 10 días</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 32px 24px; }
.header h1 { margin: 0; font-size: 22px; }
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 24px; }
.alert { background: #fffbeb; border-left: 4px solid #f59e0b; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
.alert strong { color: #92400e; }
.field { margin-bottom: 16px; }
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📋 Segunda Factura — Natur</h1>
<p>Aviso automático 10 días antes · Naturcalabacera</p>
</div>
<div class="body">
<div class="alert">
<strong>Aviso:</strong> La reserva de {{CLIENT_NAME}} comienza en 10 días. Pendiente segunda factura.
</div>
<div class="grid">
<div class="field">
<label>Cliente</label>
<value>{{CLIENT_NAME}}</value>
</div>
<div class="field">
<label>Propiedad</label>
<value>{{PROPERTY}}</value>
</div>
<div class="field">
<label>Nº Factura</label>
<value>{{INVOICE_NUMBER}}</value>
</div>
<div class="field">
<label>Personas totales</label>
<value>{{TOTAL_PERSONS}}</value>
</div>
<div class="field">
<label>Entrada</label>
<value>{{START_DATE}}</value>
</div>
<div class="field">
<label>Salida</label>
<value>{{END_DATE}}</value>
</div>
</div>
</div>
<div class="footer">
ID: {{RESERVATION_ID}} · Naturcalabacera App Reservas
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{{ACTION_LABEL}} — Natur</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
/* Headers por acción */
.header-creada { background: linear-gradient(135deg, #10b981, #065f46); }
.header-modificada { background: linear-gradient(135deg, #f59e0b, #92400e); }
.header-cancelada { background: linear-gradient(135deg, #ef4444, #7f1d1d); }
.header { color: white; padding: 28px 28px 24px; }
.header .action-pill {
display: inline-block; padding: 3px 12px; border-radius: 999px;
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em;
background: rgba(255,255,255,0.25); margin-bottom: 12px;
}
.header h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; line-height: 1.2; }
.header .subtitle { margin: 6px 0 0; opacity: 0.9; font-size: 14px; font-weight: 500; }
.header .meta { margin: 10px 0 0; opacity: 0.75; font-size: 12px; }
/* Body */
.body { padding: 28px; }
.section-title {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em;
color: #6b7280; font-weight: 700; margin: 0 0 14px;
padding-bottom: 8px; border-bottom: 1px solid #e5e7eb;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; font-weight: 600; display: block; }
.field value { display: block; font-size: 15px; font-weight: 700; color: #111; margin-top: 3px; }
.field-full { margin-bottom: 14px; }
/* Badge de origen */
.origin-badge {
display: inline-block; padding: 4px 12px; border-radius: 999px;
font-size: 12px; font-weight: 700;
background: #d1fae5; color: #065f46;
}
/* Alerta cancelación */
.cancel-alert {
background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;
padding: 14px 16px; margin-bottom: 20px;
}
.cancel-alert p { margin: 0; font-size: 14px; color: #7f1d1d; font-weight: 600; }
.footer { padding: 14px 28px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; }
</style>
</head>
<body>
<div class="container">
<!-- Header con color según acción -->
<div class="header header-{{ACTION}}">
<div class="action-pill">{{ACTION_LABEL}}</div>
<h1>{{CLIENT_NAME}}</h1>
<p class="subtitle">{{PROPERTY}} &nbsp;·&nbsp; {{NIGHTS}} &nbsp;·&nbsp; <span class="origin-badge" style="background:rgba(255,255,255,0.2); color:white;">Natur</span></p>
<p class="meta">{{DATE_RANGE}}</p>
</div>
<div class="body">
<!-- Alerta solo en cancelaciones -->
{{CANCEL_ALERT}}
<!-- Datos principales -->
<p class="section-title">Datos de la reserva</p>
<div class="grid">
<div class="field">
<label>Propiedad</label>
<value>{{PROPERTY}}</value>
</div>
<div class="field">
<label>Nº Factura</label>
<value>{{INVOICE_NUMBER}}</value>
</div>
<div class="field">
<label>Entrada</label>
<value>{{START_DATE}}</value>
</div>
<div class="field">
<label>Salida</label>
<value>{{END_DATE}}</value>
</div>
<div class="field">
<label>Adultos</label>
<value>{{ADULTS}}</value>
</div>
<div class="field">
<label>Niños</label>
<value>{{CHILDREN}}</value>
</div>
<div class="field">
<label>Registro gov.</label>
<value>{{GOVERNMENT_REG}}</value>
</div>
<div class="field">
<label>Servicios extra</label>
<value>{{SERVICES}}</value>
</div>
</div>
<!-- Observaciones (solo si existen) -->
{{OBSERVATIONS_BLOCK}}
<!-- Cambios (solo en modificaciones) -->
{{CHANGES_BLOCK}}
</div>
<div class="footer">
ID reserva: {{RESERVATION_ID}} &nbsp;·&nbsp; Naturcalabacera App Reservas
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Aviso Calefacción de Piscina</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; padding: 32px 24px; }
.header h1 { margin: 0; font-size: 22px; }
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 24px; }
.alert { background: #fef2f2; border-left: 4px solid #ef4444; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
.alert strong { color: #991b1b; }
.field { margin-bottom: 16px; }
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🌡️ Calefacción de Piscina — 48h</h1>
<p>Aviso automático · Naturcalabacera</p>
</div>
<div class="body">
<div class="alert">
<strong>Acción requerida:</strong> En 48 horas llega {{CLIENT_NAME}} a {{PROPERTY}} con calefacción de piscina activada.
Por favor, encender la calefacción de piscina con suficiente antelación.
</div>
<div class="grid">
<div class="field">
<label>Cliente</label>
<value>{{CLIENT_NAME}}</value>
</div>
<div class="field">
<label>Propiedad</label>
<value>{{PROPERTY}}</value>
</div>
<div class="field">
<label>Entrada</label>
<value>{{START_DATE}}</value>
</div>
<div class="field">
<label>Salida</label>
<value>{{END_DATE}}</value>
</div>
<div class="field">
<label>Personas totales</label>
<value>{{TOTAL_PERSONS}}</value>
</div>
</div>
</div>
<div class="footer">
ID: {{RESERVATION_ID}} · Destinatario configurable via NOTIFICATION_EMAIL_POOL_HEATING
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Recordatorio Check-in Mañana</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #10b981, #059669); color: white; padding: 32px 24px; }
.header h1 { margin: 0; font-size: 22px; }
.header p { margin: 8px 0 0; opacity: 0.85; font-size: 14px; }
.body { padding: 28px 24px; }
.alert { background: #ecfdf5; border-left: 4px solid #10b981; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
.alert strong { color: #065f46; }
.field { margin-bottom: 16px; }
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; font-weight: 600; }
.field value { display: block; font-size: 16px; font-weight: 600; color: #111; margin-top: 2px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.footer { padding: 16px 24px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 12px; color: #9ca3af; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⏰ Check-in Mañana — Los Dragos</h1>
<p>Recordatorio automático 24h · Naturcalabacera</p>
</div>
<div class="body">
<div class="alert">
<strong>Recordatorio:</strong> Mañana llega {{CLIENT_NAME}} a Los Dragos.
</div>
<div class="grid">
<div class="field">
<label>Cliente</label>
<value>{{CLIENT_NAME}}</value>
</div>
<div class="field">
<label>Nº Factura</label>
<value>{{INVOICE_NUMBER}}</value>
</div>
<div class="field">
<label>Entrada</label>
<value>{{START_DATE}}</value>
</div>
<div class="field">
<label>Salida</label>
<value>{{END_DATE}}</value>
</div>
<div class="field">
<label>Personas totales</label>
<value>{{TOTAL_PERSONS}}</value>
</div>
</div>
</div>
<div class="footer">
ID: {{RESERVATION_ID}} · Naturcalabacera App Reservas
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{{ACTION_LABEL}} — Teneriffa</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
/* Headers por acción */
.header-creada { background: linear-gradient(135deg, #3b82f6, #1d4ed8); }
.header-modificada { background: linear-gradient(135deg, #f59e0b, #92400e); }
.header-cancelada { background: linear-gradient(135deg, #ef4444, #7f1d1d); }
.header { color: white; padding: 28px 28px 24px; }
.header .action-pill {
display: inline-block; padding: 3px 12px; border-radius: 999px;
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em;
background: rgba(255,255,255,0.25); margin-bottom: 12px;
}
.header h1 { margin: 0 0 4px; font-size: 24px; font-weight: 800; line-height: 1.2; }
.header .subtitle { margin: 6px 0 0; opacity: 0.9; font-size: 14px; font-weight: 500; }
.header .meta { margin: 10px 0 0; opacity: 0.75; font-size: 12px; }
/* Body */
.body { padding: 28px; }
.section-title {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em;
color: #6b7280; font-weight: 700; margin: 0 0 14px;
padding-bottom: 8px; border-bottom: 1px solid #e5e7eb;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
.field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #9ca3af; font-weight: 600; display: block; }
.field value { display: block; font-size: 15px; font-weight: 700; color: #111; margin-top: 3px; }
/* Alerta cancelación */
.cancel-alert {
background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;
padding: 14px 16px; margin-bottom: 20px;
}
.cancel-alert p { margin: 0; font-size: 14px; color: #7f1d1d; font-weight: 600; }
.footer { padding: 14px 28px; background: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 11px; color: #9ca3af; }
</style>
</head>
<body>
<div class="container">
<!-- Header con color según acción -->
<div class="header header-{{ACTION}}">
<div class="action-pill">{{ACTION_LABEL}}</div>
<h1>{{CLIENT_NAME}}</h1>
<p class="subtitle">{{PROPERTY}} &nbsp;·&nbsp; {{NIGHTS}} &nbsp;·&nbsp; Teneriffa</p>
<p class="meta">{{DATE_RANGE}}</p>
</div>
<div class="body">
<!-- Alerta solo en cancelaciones -->
{{CANCEL_ALERT}}
<!-- Datos principales -->
<p class="section-title">Datos de la reserva</p>
<div class="grid">
<div class="field">
<label>Propiedad</label>
<value>{{PROPERTY}}</value>
</div>
<div class="field">
<label>Nº Factura</label>
<value>{{INVOICE_NUMBER}}</value>
</div>
<div class="field">
<label>Entrada</label>
<value>{{START_DATE}}</value>
</div>
<div class="field">
<label>Salida</label>
<value>{{END_DATE}}</value>
</div>
<div class="field">
<label>Adultos</label>
<value>{{ADULTS}}</value>
</div>
<div class="field">
<label>Niños</label>
<value>{{CHILDREN}}</value>
</div>
<div class="field">
<label>Registro gov.</label>
<value>{{GOVERNMENT_REG}}</value>
</div>
<div class="field">
<label>Servicios extra</label>
<value>{{SERVICES}}</value>
</div>
</div>
<!-- Observaciones (solo si existen) -->
{{OBSERVATIONS_BLOCK}}
<!-- Cambios (solo en modificaciones) -->
{{CHANGES_BLOCK}}
</div>
<div class="footer">
ID reserva: {{RESERVATION_ID}} &nbsp;·&nbsp; Naturcalabacera App Reservas
</div>
</div>
</body>
</html>