Initial commit: monorepo Naturcalabacera reservas (apps/api + apps/web + packages/shared)
This commit is contained in:
30
apps/api/.env.example
Normal file
30
apps/api/.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# apps/api — Variables de entorno
|
||||
# Copia este archivo como .env y rellena los valores.
|
||||
# NUNCA expongas estas variables al frontend.
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
# Servidor
|
||||
API_PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Supabase (self-hosted) — usar service_role key (bypasea RLS)
|
||||
SUPABASE_URL=https://tu-supabase.tudominio.com
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
|
||||
# Resend — servicio de email
|
||||
# Crear cuenta en https://resend.com y configurar dominio
|
||||
RESEND_API_KEY=re_...
|
||||
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
|
||||
NOTIFICATION_EMAIL_POOL_HEATING=jonathan@ejemplo.com # "Jonathan o las chicas"
|
||||
|
||||
# URL del frontend para CORS
|
||||
WEB_ORIGIN=http://localhost:5173
|
||||
|
||||
# Jobs — runner de notificaciones
|
||||
JOB_RUNNER_INTERVAL_MS=300000 # 5 minutos (300000ms)
|
||||
JOB_MAX_RETRIES=3
|
||||
25
apps/api/package.json
Normal file
25
apps/api/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@naturcalabacera/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --env-file=.env src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@naturcalabacera/shared": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.95.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.18",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
40
apps/api/src/config/env.ts
Normal file
40
apps/api/src/config/env.ts
Normal 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'),
|
||||
};
|
||||
252
apps/api/src/events/handler.ts
Normal file
252
apps/api/src/events/handler.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
67
apps/api/src/events/types.ts
Normal file
67
apps/api/src/events/types.ts
Normal 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
38
apps/api/src/index.ts
Normal 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
168
apps/api/src/jobs/runner.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
apps/api/src/lib/supabase.ts
Normal file
19
apps/api/src/lib/supabase.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
9
apps/api/src/routes/health.ts
Normal file
9
apps/api/src/routes/health.ts
Normal 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 };
|
||||
67
apps/api/src/routes/notifications.ts
Normal file
67
apps/api/src/routes/notifications.ts
Normal 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 };
|
||||
154
apps/api/src/routes/users.ts
Normal file
154
apps/api/src/routes/users.ts
Normal 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 };
|
||||
50
apps/api/src/services/email.ts
Normal file
50
apps/api/src/services/email.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
64
apps/api/src/templates/invoice-10d.html
Normal file
64
apps/api/src/templates/invoice-10d.html
Normal 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>
|
||||
120
apps/api/src/templates/natur-crud.html
Normal file
120
apps/api/src/templates/natur-crud.html
Normal 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}} · {{NIGHTS}} · <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}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
61
apps/api/src/templates/pool-heating-48h.html
Normal file
61
apps/api/src/templates/pool-heating-48h.html
Normal 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>
|
||||
60
apps/api/src/templates/reminder-24h.html
Normal file
60
apps/api/src/templates/reminder-24h.html
Normal 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>
|
||||
112
apps/api/src/templates/teneriffa-crud.html
Normal file
112
apps/api/src/templates/teneriffa-crud.html
Normal 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}} · {{NIGHTS}} · 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}} · Naturcalabacera App Reservas
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"paths": {
|
||||
"@naturcalabacera/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@naturcalabacera/shared/*": ["../../packages/shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user