feat: horarios opcionales en reservas, calendarios en lunes y emails filtrados

- Reservas: campos opcionales start_time/end_time (migración 011, schema natur_reservas)
  + toggle en el modal y detección de solapamiento por horario cuando ambas reservas
  los tienen definidos. Permite encajar varios eventos el mismo día.
- Calendario mensual y anual ahora empiezan en lunes; vista móvil incluida.
- Celdas con varios eventos el mismo día se dividen en franjas horizontales
  mostrando el horario; las reservas multi-día siguen ocupando la celda completa.
- Modal: reset de campos vacíos (client_name, fechas, factura) para evitar que el
  nombre de la última reserva se filtre al crear una nueva.
- Emails: las modificaciones solo disparan correo cuando cambian fechas u horas;
  el correo a Teneriffa pasa a formato reducido (solo fechas + propiedad) mientras
  que Natur sigue recibiendo el detalle completo. Mantenimiento sin cambios.
- CLAUDE.md con guía operativa (schema natur_reservas, stack, convenciones).
- Scripts de preview/envío de emails para pruebas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 11:53:34 +01:00
parent f9a8d83e5e
commit 4ce80b8fc0
12 changed files with 619 additions and 59 deletions

View File

@@ -0,0 +1,43 @@
// Genera previews HTML del correo minimalista para Teneriffa
// (las 2 reservas del 19 de mayo de Los Dragos).
// Uso: node apps/api/scripts/preview-teneriffa-email.mjs
import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(__dirname, '../preview-out');
mkdirSync(OUT_DIR, { recursive: true });
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
return `<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:${accent};color:#fff;padding:20px 24px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
</div>
<div style="padding:24px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
</div>
</div>
</body></html>`;
}
const dateRange = '19 may 2026 19 may 2026';
const property = 'Los Dragos';
const f1 = join(OUT_DIR, 'teneriffa-email-19may-evento1.html');
writeFileSync(f1, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
console.log('→', f1);
const f2 = join(OUT_DIR, 'teneriffa-email-19may-evento2.html');
writeFileSync(f2, renderTeneriffaMinimal('Nueva Reserva', dateRange, property));
console.log('→', f2);
console.log('\nAbre los archivos en el navegador para ver cómo se renderiza el correo en Teneriffa.');

View File

@@ -0,0 +1,60 @@
// Script de prueba: envía dos correos minimalistas de Teneriffa para las dos
// reservas del 19 de mayo de 2026 (Los Dragos), tal como las recibiría Teneriffa
// con el nuevo formato (solo fechas + propiedad).
// Uso: node apps/api/scripts/send-test-teneriffa-email.mjs <DESTINO>
// .env se carga vía `node --env-file=.env`
const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL;
const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera <reservas@naturcalabacera.com>';
const TO = process.argv[2] ?? 'kilian.parraga@gmail.com';
if (!WEBHOOK) {
console.error('Falta N8N_EMAIL_WEBHOOK_URL en el .env');
process.exit(1);
}
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
return `<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:${accent};color:#fff;padding:20px 24px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
</div>
<div style="padding:24px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
</div>
</div>
</body></html>`;
}
async function send({ subject, html }) {
const res = await fetch(WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: TO, subject, html, from: FROM }),
});
const text = await res.text().catch(() => '');
return { ok: res.ok, status: res.status, body: text.slice(0, 200) };
}
const dateRange = '19 may 2026 19 may 2026';
const property = 'Los Dragos';
// Reserva #1: evento mañana
const r1 = await send({
subject: `[NUEVA RESERVA] ${dateRange} · ${property}`,
html: renderTeneriffaMinimal('Nueva Reserva', dateRange, property),
});
console.log('Email #1 (Pruebas Kilian 10:0011:00):', r1);
// Reserva #2: evento tarde
const r2 = await send({
subject: `[NUEVA RESERVA] ${dateRange} · ${property}`,
html: renderTeneriffaMinimal('Nueva Reserva', dateRange, property),
});
console.log('Email #2 (Pruebas Kilian 12:0020:00):', r2);

View File

@@ -0,0 +1,50 @@
// Envía 3 ejemplos del correo minimalista de Teneriffa: nueva, modificada, cancelada.
// Uso: N8N_EMAIL_WEBHOOK_URL=... node apps/api/scripts/send-test-teneriffa-variants.mjs <destino>
const WEBHOOK = process.env.N8N_EMAIL_WEBHOOK_URL;
const FROM = process.env.EMAIL_FROM ?? 'Naturcalabacera <reservas@naturcalabacera.com>';
const TO = process.argv[2] ?? 'kilian.parraga@gmail.com';
if (!WEBHOOK) { console.error('Falta N8N_EMAIL_WEBHOOK_URL'); process.exit(1); }
function renderTeneriffaMinimal(actionLabel, dateRange, property, cancelled = false) {
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
return `<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:${accent};color:#fff;padding:20px 24px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
</div>
<div style="padding:24px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
</div>
</div>
</body></html>`;
}
async function send(subject, html) {
const res = await fetch(WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: TO, subject, html, from: FROM }),
});
const text = await res.text().catch(() => '');
return { ok: res.ok, status: res.status, body: text.slice(0, 200) };
}
const cases = [
{ tag: 'NUEVA RESERVA', label: 'Nueva Reserva', range: '03 jul 2026 10 jul 2026', prop: 'La Esquinita' },
{ tag: 'MODIFICADA', label: 'Reserva Modificada', range: '03 jul 2026 12 jul 2026', prop: 'La Esquinita' },
{ tag: 'CANCELADA', label: 'Reserva Cancelada', range: '03 jul 2026 12 jul 2026', prop: 'La Esquinita', cancelled: true },
];
for (const c of cases) {
const subject = `[${c.tag}] ${c.range} · ${c.prop}`;
const html = renderTeneriffaMinimal(c.label, c.range, c.prop, c.cancelled);
const r = await send(subject, html);
console.log(c.tag.padEnd(15), r);
}

View File

@@ -146,6 +146,89 @@ interface SendResult {
error?: string;
}
/**
* Campos cuya modificación dispara el email de "reserva modificada".
* El resto se considera cambio menor (observaciones, registro, factura,
* snapshot de precios, servicios, asistentes, etc.) y NO envía correo
* para no saturar las bandejas de entrada.
*/
const MAJOR_UPDATE_FIELDS: (keyof Reservation)[] = [
'start_date',
'end_date',
'start_time',
'end_time',
];
function hasMajorChange(prev: Reservation, curr: Reservation): boolean {
return MAJOR_UPDATE_FIELDS.some(field => {
const a = prev[field] ?? '';
const b = curr[field] ?? '';
return a !== b;
});
}
/**
* Email reducido para Teneriffa: fechas + propiedad + acción.
* Sin datos del cliente, sin servicios, sin observaciones.
*/
function renderTeneriffaMinimal(
actionLabel: string,
dateRange: string,
property: string,
cancelled = false,
): string {
const accent = cancelled ? '#ef4444' : actionLabel === 'Nueva Reserva' ? '#3b82f6' : '#f59e0b';
return `<!DOCTYPE html>
<html lang="es"><head><meta charset="UTF-8"></head>
<body style="font-family:Arial,sans-serif;background:#f4f4f4;margin:0;padding:24px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<div style="background:${accent};color:#fff;padding:20px 24px;">
<p style="margin:0;font-size:11px;text-transform:uppercase;letter-spacing:0.1em;opacity:0.85;font-weight:700;">${actionLabel}</p>
</div>
<div style="padding:24px;">
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Fechas</p>
<p style="margin:0 0 18px;font-size:18px;font-weight:800;color:#111;">${dateRange}</p>
<p style="margin:0 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:0.06em;color:#9ca3af;font-weight:700;">Propiedad</p>
<p style="margin:0;font-size:16px;font-weight:700;color:#111;">${property}</p>
</div>
</div>
</body></html>`;
}
async function sendCrudPair(opts: {
actionLabel: string;
subjectTag: string;
dateRange: string;
property: string;
template: 'teneriffa-crud' | 'natur-crud';
vars: Record<string, string>;
originLabel: string;
clientName: string;
cancelled?: boolean;
}): Promise<SendResult> {
// 1) Correo reducido a Teneriffa (solo fechas + propiedad)
const minHtml = renderTeneriffaMinimal(opts.actionLabel, opts.dateRange, opts.property, opts.cancelled);
const minSubject = `[${opts.subjectTag}] ${opts.dateRange} · ${opts.property}`;
const minRes = await sendEmail({
to: env.NOTIFICATION_EMAIL_TENERIFFA,
subject: minSubject,
html: minHtml,
});
// 2) Correo completo a Natur (HTML con todos los detalles)
const fullHtml = renderTemplate(opts.template, opts.vars);
const fullSubject = `[${opts.subjectTag}] ${opts.originLabel}${opts.clientName} | ${opts.property} | ${opts.dateRange}`;
const fullRes = await sendEmail({
to: env.NOTIFICATION_EMAIL_NATUR,
subject: fullSubject,
html: fullHtml,
});
if (!minRes.success) return minRes;
if (!fullRes.success) return fullRes;
return { success: true };
}
/**
* Envía el email correspondiente a un evento de notificación.
* Para reservation.updated acepta previousReservation para mostrar el diff.
@@ -189,12 +272,24 @@ export async function handleNotificationEvent(
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 });
return sendCrudPair({
actionLabel: 'Nueva Reserva',
subjectTag: 'NUEVA RESERVA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
});
}
case 'reservation.updated': {
// Skip si los cambios son solo en campos menores (observaciones, factura, servicios, etc.)
// Solo dispara email cuando cambian fechas u horarios. Evita inundar bandejas.
if (previousReservation && !hasMajorChange(previousReservation, reservation)) {
return { success: true };
}
const isTeenriffa = reservation.origin === 'Teneriffa2000';
const template = isTeenriffa ? 'teneriffa-crud' : 'natur-crud';
const originLabel = isTeenriffa ? 'Teneriffa' : 'Natur';
@@ -207,9 +302,16 @@ export async function handleNotificationEvent(
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 });
return sendCrudPair({
actionLabel: 'Reserva Modificada',
subjectTag: 'MODIFICADA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
});
}
case 'reservation.cancelled': {
@@ -223,9 +325,17 @@ export async function handleNotificationEvent(
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 });
return sendCrudPair({
actionLabel: 'Reserva Cancelada',
subjectTag: 'CANCELADA',
dateRange,
property: propertyLabel(reservation.property),
template,
vars,
originLabel,
clientName: reservation.client_name,
cancelled: true,
});
}
case 'reservation.reminder_24h': {