← Estado del proyectoKai Club · Docs

DATABASE_SCHEMA.md — KAI CLUB

PostgreSQL 16 + Prisma 6. 17 tablas — cada una existe porque un requisito real la exige; ninguna «por si acaso». Campos i18n = JSONB {"es":"…","en":"…","de":"…"}. Dinero en céntimos (_cents, int). Todo created_at/updated_at.

Diagrama lógico

users ──< audit_logs
products ──< bookings >── customers          products = experiencias + reservados (POOLED | UNIQUE)
bookings ──< booking_extras >── extras
menu_categories ──< menu_items               (FOOD y DRINK en el mismo sistema)
events                                        (agenda pública)
private_event_leads >── customers?           (CELEBRATE AT KAI)
gallery_media · reviews · kai_today(1) · settings(kv) · calendar_overrides · mail_outbox

Tablas

users

id, email UNIQUE, password_hash (argon2), name, role ENUM(ADMIN|MANAGER|STAFF), active, last_login_at Solo personal. Sin cuentas públicas en v1.

settings (clave-valor tipado)

key TEXT PK, value JSONB, updated_by Claves: contact (tel, whatsapp por contexto, email, dirección, gmaps_url/place_id), hours (por día de semana), booking (modo_pago default, minutos_expiración_pending, antelación_max_días), social, grant_notice (subvención EU), whatsapp_ctas (mensajes preconfigurados por contexto).

products ← experiencias Y reservados

id, slug UNIQUE, type ENUM(EXPERIENCE|RESERVADO), inventory_mode ENUM(POOLED|UNIQUE)
name JSONB, tagline JSONB, description JSONB, includes JSONB (array i18n), conditions JSONB
price_access_cents        -- 20€/15€/5€/15€ p.p.
min_consumption_cents     -- 40€ p.p. | 100–900€ por unidad
min_consumption_scope ENUM(PER_PERSON|PER_UNIT)
capacity_per_day INT      -- POOLED: nº hamacas del tier; UNIQUE: 1
max_pax INT               -- Aloha 2, Kaikane 9, Keola/Kamala 10, Makai 15
child_product_id FK NULL  -- Experiencia Infantil ligada a las de adulto
valid_days JSONB          -- {weekdays:[6,0], holidays:true}  (sáb/dom/festivos)
payment_mode ENUM(NONE|DEPOSIT|FULL), deposit_cents
hero_image, gallery JSONB, features JSONB (piscina privada, cama balinesa, jarras incluidas…)
sort, active
-- futuro pool map real: map_shape_id NULL (se rellenará cuando exista SVG del plano; no se construye ahora)

Seed real (del póster): EXP 1ª línea (POOLED, 20€+40€pp), EXP 2ª-3ª (POOLED, 15€+40€pp), Infantil (5€+20€), Keola (UNIQUE 600€+15€pp, 10pax), Kamala (600€), Kaikane (500€, 9pax), Aloha (100€, 2pax), Makai (900€, 15pax).

calendar_overrides

id, date UNIQUE, closed BOOL, is_holiday BOOL, note JSONB, capacity_overrides JSONB ({product_id: n}) Cierres (mar-mié via settings.hours; excepciones aquí), festivos canarios (activan tarifas de experiencia entre semana), capacidad especial.

bookings

id, code UNIQUE (KAI-XXXXXX), product_id FK, date DATE, status ENUM(PENDING|CONFIRMED|SEATED|COMPLETED|CANCELLED|NO_SHOW|EXPIRED)
customer_id FK, pax_adults, pax_children
access_total_cents, min_consumption_total_cents, extras_total_cents, deposit_cents
payment_status ENUM(NONE|PENDING|PAID|REFUNDED), stripe_payment_intent_id NULL
locale, source ENUM(WEB|PHONE|WHATSAPP|WALKIN|ADMIN), notes_customer, notes_staff
expires_at (PENDING+20min), confirmed_at, cancelled_at, cancel_reason

Índices: (date, status), (customer_id), parcial único (product_id, date) WHERE inventory UNIQUE y status IN (PENDING,CONFIRMED) (anti doble-reserva de reservados). POOLED se valida en transacción serializable.

extras / booking_extras

extras: id, name JSONB, price_cents, type ENUM(CAKE|DRINK|DECOR|OTHER), active — tarta de cumpleaños (existe hoy), jarra extra… booking_extras: booking_id, extra_id, qty, price_cents_snapshot

customers (CRM ligero, RGPD-mínimo)

id, name, email, phone, locale, country NULL
consent_marketing BOOL + consent_marketing_at, consent_terms_at
bookings_count, last_visit_at, total_spent_cents  (agregados por cron/trigger)
notes  -- solo operativas, visibles al cliente si las pide (RGPD)

Sin fecha de nacimiento, sin DNI, sin nada innecesario. Export + anonimización desde admin.

private_event_leads

id, type ENUM(WEDDING|BIRTHDAY|CORPORATE|PRIVATE_PARTY|OTHER), event_date NULL, date_flexible BOOL
guests_estimate, services JSONB ([food, cocktails, open_bar, dj, live_music, decor, photo, video, private_area, full_venue])
name, phone, email, message, locale
status ENUM(NEW|CONTACTED|QUOTED|WON|LOST), status_notes, value_estimate_cents NULL
customer_id FK NULL, consent_at

Pipeline comercial simple en admin (kanban por status). Aviso inmediato al equipo (outbox + notificación).

menu_categories / menu_items

menu_categories: id, kind ENUM(FOOD|DRINK), name JSONB, sort, active
menu_items: id, category_id FK, name JSONB, description JSONB
  price_cents NULL, price_note NULL ("S/M", "7€/100 gr", "6,5€ copa / 32€ botella")
  allergens JSONB, vegetarian, vegan, gluten_free, spicy BOOL, featured BOOL, available BOOL
  image NULL, sort

Seed real: carta completa extraída de los PNG actuales (COMIDA: picoteo/emparedados/peques/brasas/entrantes/pescado/carnes/guarniciones/postres · BEBIDAS: cocktails/mojitos/frozen/sin alcohol/smoothies/sangría/vinos/champagne/cava) — marcada [CONFIRMAR] hasta validación del cliente.

events

id, slug UNIQUE, title JSONB, description JSONB, date, time_start, time_end NULL, artist NULL, type ENUM(DJ|LIVE|SUNSET|SPECIAL|GASTRO), image, video NULL, price_note JSONB NULL, booking_mode ENUM(NONE|LINK|INTERNAL), booking_url NULL, active, sort Sin eventos inventados: se lanza vacío y el admin los crea. Home muestra «NEXT AT KAI» solo si hay futuros.

gallery_media

id, category ENUM(POOL|FOOD|DRINKS|PEOPLE|SUNSET|EVENTS|VENUE), src, variants JSONB (avif/webp/srcset), alt JSONB, width, height, video BOOL, poster NULL, sort, active

reviews

id, source ENUM(GOOGLE|TRIPADVISOR|MANUAL), author, rating SMALLINT, text JSONB|TEXT, lang, review_date, approved BOOL, sort Curación manual desde admin (fiabilidad > API frágil). Nunca inventadas: se importan copiando de la fuente con enlace.

kai_today (singleton)

id=1, message JSONB NULL, dj_name NULL, dj_time NULL, pool_status ENUM(OPEN|CLOSED|NULL), restaurant_status, availability_note JSONB NULL, promo JSONB NULL, show_weather BOOL, updated_at, updated_by Clima/sunset NO se guardan: se calculan/cachean en servidor (Open-Meteo + cálculo solar). Si un dato no existe → no se muestra. Disponibilidad real sale de bookings, jamás se teclea a mano.

mail_outbox

id, to_email, template, payload JSONB, locale, status ENUM(QUEUED|SENT|FAILED), attempts, last_error, sent_at Confirmaciones, recordatorio T-1día, gracias/reseña T+1día, aviso lead privado. Funciona sin SMTP (encola).

audit_logs

id, user_id FK, action, entity, entity_id, payload JSONB, ip, created_at Cambios de precios/productos, cancelaciones, exports de datos, login fallido/ok.

Integridad y rendimiento