diff --git a/dev.log b/dev.log new file mode 100644 index 0000000..27e9388 Binary files /dev/null and b/dev.log differ diff --git a/scripts/fix-kiosk-func.js b/scripts/fix-kiosk-func.js new file mode 100644 index 0000000..0aaed3c --- /dev/null +++ b/scripts/fix-kiosk-func.js @@ -0,0 +1,88 @@ +require('dotenv').config({ path: '.env.local' }); +const { createClient } = require('@supabase/supabase-js'); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing env vars'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function fixDb() { + console.log('Applying DB fix...'); + + const sql = ` +CREATE OR REPLACE FUNCTION generate_kiosk_api_key() +RETURNS VARCHAR(64) AS $$ +DECLARE + chars VARCHAR(62) := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + v_api_key VARCHAR(64); + attempts INT := 0; + max_attempts INT := 10; +BEGIN + LOOP + v_api_key := ''; + FOR i IN 1..64 LOOP + v_api_key := v_api_key || substr(chars, floor(random() * 62 + 1)::INT, 1); + END LOOP; + + IF NOT EXISTS (SELECT 1 FROM kiosks WHERE api_key = v_api_key) THEN + RETURN v_api_key; + END IF; + + attempts := attempts + 1; + IF attempts >= max_attempts THEN + RAISE EXCEPTION 'Failed to generate unique api_key after % attempts', max_attempts; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + `; + + const { error } = await supabase.rpc('exec_sql', { sql_query: sql }); + + // Wait, I might not have 'exec_sql' RPC function available unless I added it. + // Standard Supabase doesn't have it by default. + // I can try to use standard pg connection if I have the connection string. + // But I only have the URL and Key. + // BUT: The error happened in a function that is part of my migration. + + // Alternative: If I don't have direct SQL execution, I can't easily patch the DB function + // without a migration tool. + // However, I can try to see if I can drop/recreate via some available mechanism or + // maybe the 'exec_sql' exists (some starters have it). + + if (error) { + console.error('RPC exec_sql failed (might not exist):', error); + + // Fallback: If I can't execute SQL, I'm stuck unless I have a way to run migrations. + // I noticed `db/migrate.sh` in package.json. Maybe I can run that? + // But I don't have `db` folder locally in the listing. + // I only have `supabase` folder. + + console.log('Trying to use direct postgres connection if connection string available...'); + // I don't have the connection string in .env.example, only the URL. + // Usually the URL is http... + + // Let's assume I CANNOT run raw SQL easily. + // BUT I can try to "re-apply" a migration if I had the tool. + + process.exit(1); + } else { + console.log('Fix applied successfully!'); + } +} + +// Actually, let's check if I can use a simpler approach. +// I can CREATE a new migration file with the fix and ask the user to run it? +// Or I can use the `postgres` package if I can derive the connection string. +// But I don't have the DB password. +// The service role key allows me to use the API as superuser (sort of), but not run DDL +// unless I have an RPC for it. + +// Let's check if there is an `exec_sql` or similar function. +// I'll try to run a simple query. +fixDb(); diff --git a/scripts/fix-short-id.sql b/scripts/fix-short-id.sql new file mode 100644 index 0000000..b35c7be --- /dev/null +++ b/scripts/fix-short-id.sql @@ -0,0 +1,26 @@ +-- Fix short_id variable name collision +CREATE OR REPLACE FUNCTION generate_short_id() +RETURNS VARCHAR(6) AS $$ +DECLARE + chars VARCHAR(36) := '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + v_short_id VARCHAR(6); + attempts INT := 0; + max_attempts INT := 10; +BEGIN + LOOP + v_short_id := ''; + FOR i IN 1..6 LOOP + v_short_id := v_short_id || substr(chars, floor(random() * 36 + 1)::INT, 1); + END LOOP; + + IF NOT EXISTS (SELECT 1 FROM bookings WHERE short_id = v_short_id) THEN + RETURN v_short_id; + END IF; + + attempts := attempts + 1; + IF attempts >= max_attempts THEN + RAISE EXCEPTION 'Failed to generate unique short_id after % attempts', max_attempts; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/scripts/setup-kiosk.js b/scripts/setup-kiosk.js new file mode 100644 index 0000000..01cc915 --- /dev/null +++ b/scripts/setup-kiosk.js @@ -0,0 +1,73 @@ +require('dotenv').config({ path: '.env.local' }); +const { createClient } = require('@supabase/supabase-js'); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function setupKiosk() { + console.log('Setting up Kiosk...'); + + // 1. Get a location + const { data: locations, error: locError } = await supabase + .from('locations') + .select('id, name') + .limit(1); + + if (locError || !locations || locations.length === 0) { + console.error('Error fetching locations or no locations found:', locError); + return; + } + + const location = locations[0]; + console.log(`Using location: ${location.name} (${location.id})`); + + // 2. Check if kiosk exists + const { data: existingKiosks, error: kioskError } = await supabase + .from('kiosks') + .select('id, api_key') + .eq('location_id', location.id) + .eq('device_name', 'TEST_KIOSK_DEVICE'); + + if (existingKiosks && existingKiosks.length > 0) { + console.log('Test Kiosk already exists.'); + console.log('API_KEY=' + existingKiosks[0].api_key); + return existingKiosks[0].api_key; + } + + // 3. Create Kiosk (Direct Insert to bypass broken SQL function) + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let apiKey = ''; + for (let i = 0; i < 64; i++) { + apiKey += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + const { data: newKiosk, error: createError } = await supabase + .from('kiosks') + .insert({ + location_id: location.id, + device_name: 'TEST_KIOSK_DEVICE', + display_name: 'Test Kiosk Display', + api_key: apiKey, + ip_address: '127.0.0.1' + }) + .select() + .single(); + + if (createError) { + console.error('Error creating kiosk:', createError); + return; + } + + console.log('Kiosk created successfully!'); + console.log('API_KEY=' + newKiosk.api_key); + return newKiosk.api_key; +} + +setupKiosk(); diff --git a/scripts/setup-staff-availability.js b/scripts/setup-staff-availability.js new file mode 100644 index 0000000..cf6a530 --- /dev/null +++ b/scripts/setup-staff-availability.js @@ -0,0 +1,48 @@ +require('dotenv').config({ path: '.env.local' }); +const { createClient } = require('@supabase/supabase-js'); + +const supabase = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); + +async function setupStaffAvailability() { + console.log('Setting up staff availability...'); + + const locationId = '90d200c5-55dd-4726-bc23-e32ca0c5655b'; + + const { data: staff, error: staffError } = await supabase + .from('staff') + .select('id, display_name') + .eq('location_id', locationId) + .is('is_active', true); + + if (staffError || !staff || staff.length === 0) { + console.error('Error fetching staff:', staffError); + return; + } + + console.log(`Found ${staff.length} staff members`); + + for (const member of staff) { + const { error: updateError } = await supabase + .from('staff') + .update({ + work_hours_start: '09:00:00', + work_hours_end: '20:00:00', + work_days: 'MON,TUE,WED,THU,FRI,SAT', + is_available_for_booking: true + }) + .eq('id', member.id); + + if (updateError) { + console.error(`Error updating ${member.display_name}:`, updateError); + } else { + console.log(`✓ Updated ${member.display_name}`); + } + } + + console.log('\n✅ Staff availability setup complete!'); +} + +setupStaffAvailability(); diff --git a/scripts/test-kiosk-complete.js b/scripts/test-kiosk-complete.js new file mode 100644 index 0000000..c816576 --- /dev/null +++ b/scripts/test-kiosk-complete.js @@ -0,0 +1,165 @@ +const API_KEY = process.argv[2]; +const BASE_URL = 'http://localhost:3000'; + +if (!API_KEY) { + console.error('Please provide API KEY as argument'); + process.exit(1); +} + +require('dotenv').config({ path: '.env.local' }); +const { createClient } = require('@supabase/supabase-js'); +const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); + +async function runTests() { + console.log('Starting Complete Kiosk API Tests...\n'); + + let createdBooking = null; + + try { + // Test 1: Authenticate + console.log('--- Test 1: Authenticate ---'); + const authRes = await fetch(`${BASE_URL}/api/kiosk/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_key: API_KEY }) + }); + + console.log('Status:', authRes.status); + const authData = await authRes.json(); + console.log('Response:', JSON.stringify(authData, null, 2)); + + if (authRes.status !== 200) throw new Error('Authentication failed'); + console.log('✅ Auth Test Passed\n'); + + // Test 2: Get Bookings for today + console.log('--- Test 2: Get Bookings for Today ---'); + const today = new Date().toISOString().split('T')[0]; + const getBookingsRes = await fetch( + `${BASE_URL}/api/kiosk/bookings?date=${today}`, + { headers: { 'x-kiosk-api-key': API_KEY } } + ); + + console.log('Status:', getBookingsRes.status); + const getBookingsData = await getBookingsRes.json(); + console.log(`Found ${getBookingsData.bookings?.length || 0} bookings today`); + + if (getBookingsRes.status !== 200) throw new Error('Get bookings failed'); + console.log('✅ Get Bookings Test Passed\n'); + + // Test 3: Get Available Resources + console.log('--- Test 3: Get Available Resources ---'); + const now = new Date(); + const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000); + + const resourcesRes = await fetch( + `${BASE_URL}/api/kiosk/resources/available?start_time=${now.toISOString()}&end_time=${oneHourFromNow.toISOString()}`, + { headers: { 'x-kiosk-api-key': API_KEY } } + ); + + console.log('Status:', resourcesRes.status); + const resourcesData = await resourcesRes.json(); + console.log(`Available resources: ${resourcesData.total_available}`); + + if (resourcesRes.status !== 200) throw new Error('Get resources failed'); + console.log('✅ Get Resources Test Passed\n'); + + // Test 4: Create Booking (Scheduled) + console.log('--- Test 4: Create Scheduled Booking ---'); + + const { data: services } = await supabase + .from('services') + .select('id, name, duration_minutes') + .eq('is_active', true) + .limit(1); + + const { data: staff } = await supabase + .from('staff') + .select('id, display_name') + .eq('is_active', true) + .limit(1); + + if (!services || services.length === 0 || !staff || staff.length === 0) { + console.error('No services or staff available'); + return; + } + + const scheduledStartTime = new Date(); + scheduledStartTime.setHours(scheduledStartTime.getHours() + 2); + + const createBookingRes = await fetch(`${BASE_URL}/api/kiosk/bookings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-kiosk-api-key': API_KEY + }, + body: JSON.stringify({ + customer_email: `scheduled_${Date.now()}@example.com`, + customer_name: 'Scheduled Customer', + customer_phone: '+525512345678', + service_id: services[0].id, + staff_id: staff[0].id, + start_time_utc: scheduledStartTime.toISOString(), + notes: 'Scheduled booking test' + }) + }); + + console.log('Status:', createBookingRes.status); + const createBookingData = await createBookingRes.json(); + console.log('Response:', JSON.stringify(createBookingData, null, 2)); + + if (createBookingRes.status !== 201) throw new Error('Create booking failed'); + createdBooking = createBookingData.booking; + console.log('✅ Create Booking Test Passed\n'); + + // Test 5: Confirm Booking + console.log('--- Test 5: Confirm Booking ---'); + if (createdBooking && createdBooking.short_id) { + const confirmRes = await fetch( + `${BASE_URL}/api/kiosk/bookings/${createdBooking.short_id}/confirm`, + { + method: 'POST', + headers: { 'x-kiosk-api-key': API_KEY } + } + ); + + console.log('Status:', confirmRes.status); + const confirmData = await confirmRes.json(); + console.log('Response:', JSON.stringify(confirmData, null, 2)); + + if (confirmRes.status !== 200) throw new Error('Confirm booking failed'); + console.log('✅ Confirm Booking Test Passed\n'); + } + + // Test 6: Walk-in Booking + console.log('--- Test 6: Walk-in Booking ---'); + const walkinRes = await fetch(`${BASE_URL}/api/kiosk/walkin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-kiosk-api-key': API_KEY + }, + body: JSON.stringify({ + customer_email: `walkin_${Date.now()}@example.com`, + customer_name: 'Walk-in Customer', + service_id: services[0].id, + notes: 'Automated Walk-in Test' + }) + }); + + console.log('Status:', walkinRes.status); + const walkinData = await walkinRes.json(); + console.log('Response:', JSON.stringify(walkinData, null, 2)); + + if (walkinRes.status !== 201) throw new Error('Walk-in failed'); + console.log('✅ Walk-in Test Passed\n'); + + console.log('✅✅✅ ALL TESTS PASSED ✅✅✅'); + process.exit(0); + + } catch (e) { + console.error('\n❌ Test Failed:', e.message); + process.exit(1); + } +} + +runTests(); diff --git a/scripts/test-kiosk.js b/scripts/test-kiosk.js new file mode 100644 index 0000000..a562982 --- /dev/null +++ b/scripts/test-kiosk.js @@ -0,0 +1,119 @@ +const API_KEY = process.argv[2]; +const BASE_URL = 'http://localhost:3000'; + +if (!API_KEY) { + console.error('Please provide API KEY as argument'); + process.exit(1); +} + +async function runTests() { + console.log('Starting Kiosk API Tests...'); + + // 1. Authenticate + console.log('\n--- Test 1: Authenticate ---'); + try { + const authRes = await fetch(`${BASE_URL}/api/kiosk/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ api_key: API_KEY }) + }); + + console.log('Status:', authRes.status); + const authData = await authRes.json(); + console.log('Response:', authData); + + if (authRes.status !== 200) throw new Error('Authentication failed'); + } catch (e) { + console.error('Auth Test Failed:', e); + process.exit(1); + } + + // 2. Get Resources + console.log('\n--- Test 2: Get Available Resources ---'); + + // Generate time window for today + const now = new Date(); + const oneHourFromNow = new Date(now.getTime() + 60 * 60 * 1000); + const startTime = now.toISOString(); + const endTime = oneHourFromNow.toISOString(); + + console.log(`Time window: ${startTime} to ${endTime}`); + + try { + const resRes = await fetch(`${BASE_URL}/api/kiosk/resources/available?start_time=${encodeURIComponent(startTime)}&end_time=${encodeURIComponent(endTime)}`, { + headers: { + 'x-kiosk-api-key': API_KEY + } + }); + + console.log('Status:', resRes.status); + const resData = await resRes.json(); + console.log('Response:', resData); + + if (resRes.status !== 200) throw new Error('Get Resources failed'); + } catch (e) { + console.error('Resources Test Failed:', e); + process.exit(1); + } + + // 3. Walk-in Booking + console.log('\n--- Test 3: Walk-in Booking ---'); + + // Need a service ID. I'll fetch it from the DB via a public endpoint if available, + // or assuming I can't reach DB here easily without duplicating setup logic. + // Wait, I can use the same setup logic or just try a known ID if I knew one. + // But I don't. + // I will use `setup-kiosk.js` to also print a service ID or just fetch it here if I use supabase client. + // But this script is meant to test HTTP API. + // I will assume I can find a service if I query the DB. + + // Better approach: Since I am running this locally, I can use the supabase client here too just to get a service ID. + require('dotenv').config({ path: '.env.local' }); + const { createClient } = require('@supabase/supabase-js'); + const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); + + const { data: services } = await supabase + .from('services') + .select('id, name') + .limit(1); + + if (!services || services.length === 0) { + console.error('No services found to test walk-in'); + return; + } + + const serviceId = services[0].id; + console.log(`Using Service: ${services[0].name} (${serviceId})`); + + try { + const walkinRes = await fetch(`${BASE_URL}/api/kiosk/walkin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-kiosk-api-key': API_KEY + }, + body: JSON.stringify({ + customer_email: `walkin_test_${Date.now()}@example.com`, + customer_name: 'Test Walkin', + service_id: serviceId, + notes: 'Automated Test' + }) + }); + + console.log('Status:', walkinRes.status); + const walkinData = await walkinRes.json(); + console.log('Response:', JSON.stringify(walkinData, null, 2)); + + if (walkinRes.status !== 201) throw new Error('Walk-in failed: ' + walkinData.error); + + console.log('✅ WALK-IN TEST PASSED'); + } catch (e) { + console.error('Walk-in Test Failed:', e); + process.exit(1); + } + + console.log('\n✅ ALL TESTS PASSED'); + process.exit(0); +} + +runTests(); diff --git a/server.log b/server.log new file mode 100644 index 0000000..e69de29 diff --git a/site_mockup.png b/site_mockup.png new file mode 100644 index 0000000..8bb8100 Binary files /dev/null and b/site_mockup.png differ diff --git a/site_requirements.md b/site_requirements.md new file mode 100644 index 0000000..fcbbc15 --- /dev/null +++ b/site_requirements.md @@ -0,0 +1,211 @@ +# Anchor:23 — Site Requirements + +Documento de ejecución para OpenCode / Codex. +Define identidad visual, estructura del sitio, copy y reglas de implementación. + +--- + +## 1. Objetivo del sitio + +* Representar a Anchor:23 como concepto de belleza de ultra lujo. +* Comunicar exclusividad basada en estándar, no en aspiración. +* Separar marca institucional de sistemas operativos (booking / kiosk). +* Convertir sin presión: membresía y cita. + +--- + +## 2. Arquitectura de dominios + +* `anchor23.mx` — Sitio institucional +* `booking.anchor23.mx` — Sistema de reservas (The Boutique) +* `kiosk.anchor23.mx` — UI táctil en sucursal + +El sitio **anchor23.mx** no contiene lógica compleja. +Es contenido, marca y narrativa. + +--- + +## 3. Paleta de Color + +### Base + +* Bone White `#F6F1EC` — fondo principal +* Soft Cream `#EFE7DE` — bloques y secciones +* Mocha Taupe `#B8A89A` — íconos y divisores +* Deep Earth `#6F5E4F` — botones primarios +* Charcoal Brown `#3F362E` — texto principal / footer + +Reglas: + +* Sin colores saturados +* Sin gradientes +* Sin sombras duras + +--- + +## 4. Tipografía + +### Headings + +* Serif editorial sobria +* Ejemplos: The Seasons, Canela + +### Body / UI + +* Sans neutral +* Ejemplos: Inter, DM Sans + +Reglas: + +* Mucho espacio +* Jerarquía estricta +* Peso visual contenido + +--- + +## 5. Layout + +* Grid amplio +* Márgenes generosos +* Ritmo vertical lento +* Espacio negativo dominante + +Nunca: + +* UI densa +* Animaciones llamativas +* Efectos innecesarios + +--- + +## 6. Header + +### Navegación + +* Inicio +* Nosotros +* Servicios +* Membresías + +CTA principal: + +* Solicitar Membresía + +--- + +## 7. Landing Page + +### Hero + +**Título** +ANCHOR:23 + +**Subtítulo** +Belleza anclada en exclusividad + +**Texto** +Un estándar exclusivo de lujo y precisión. + +**CTAs** + +* Ver servicios +* Solicitar cita + +--- + +### Fundamento + +**Título** +Fundamento + +**Subtítulo** +Nada sólido nace del caos + +**Texto** +Anchor:23 nace de la unión de dos creativos que creen en el lujo no como promesa, sino como estándar. + +Aquí, lo excepcional es norma: una experiencia exclusiva y coherente, diseñada para quienes entienden que el verdadero lujo está en la precisión, no en el exceso. + +--- + +### Servicios Exclusivos (Preview) + +#### Spa de Alta Gama + +Sauna y spa excepcionales, diseñados para el rejuvenecimiento y el equilibrio. + +#### Arte y Manicure de Precisión + +Estilización y técnica donde el detalle define el resultado. + +#### Peinado y Maquillaje de Lujo + +Transformaciones discretas y sofisticadas para ocasiones selectas. + +CTA: + +* Ver todos los servicios + +--- + +### Testimonios + +Título: +Testimonios + +Ejemplos: + +* "La atención al detalle define el lujo real." — Gabriela M. +* "Exclusivo sin ser pretencioso." — Lorena T. + +CTA: + +* Solicitar Membresía + +--- + +## 8. Footer + +Contenido: + +* Marca y ciudad +* Links: Nosotros, Servicios, Contacto +* Legal: Privacy Policy, Legal +* Teléfono y correo + +--- + +## 9. Páginas internas + +### /servicios + +* Listado completo +* CTA a booking.anchor23.mx + +### /historia + +* Origen de Anchor +* Significado de : y 23 + +### /contacto + +* Formulario +* Datos de contacto + +### /franchises + +* Modelo: una sucursal por ciudad +* No franquicia masiva + +--- + +## 10. Principios de ejecución + +* HTML semántico +* CSS limpio +* JS mínimo +* Accesibilidad básica +* Performance sobre efectos + +El sitio debe sentirse silencioso, sólido y permanente. + diff --git a/supabase/migrations/20260116040000_availability_system.sql.backup b/supabase/migrations/20260116040000_availability_system.sql.backup new file mode 100644 index 0000000..28adb92 --- /dev/null +++ b/supabase/migrations/20260116040000_availability_system.sql.backup @@ -0,0 +1,369 @@ +-- ============================================ +-- FASE 2.1 - DISPONIBILIDAD DOBLE CAPA (INCREMENTAL) +-- ============================================ + +-- PASO 1: AGREGAR CAMPOS DE HORARIO A STAFF +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'staff' AND column_name = 'work_hours_start') THEN + ALTER TABLE staff ADD COLUMN work_hours_start TIME; + RAISE NOTICE 'Added work_hours_start to staff'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'staff' AND column_name = 'work_hours_end') THEN + ALTER TABLE staff ADD COLUMN work_hours_end TIME; + RAISE NOTICE 'Added work_hours_end to staff'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'staff' AND column_name = 'work_days') THEN + ALTER TABLE staff ADD COLUMN work_days TEXT DEFAULT 'MON,TUE,WED,THU,FRI,SAT'; + RAISE NOTICE 'Added work_days to staff'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'staff' AND column_name = 'is_available_for_booking') THEN + ALTER TABLE staff ADD COLUMN is_available_for_booking BOOLEAN DEFAULT true; + RAISE NOTICE 'Added is_available_for_booking to staff'; + END IF; +END +$$; + +-- PASO 2: CREAR TABLA STAFF_AVAILABILITY +CREATE TABLE IF NOT EXISTS staff_availability ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, + date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_available BOOLEAN DEFAULT true, + reason VARCHAR(255), + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES staff(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES staff(id) ON DELETE SET NULL, + UNIQUE(staff_id, date) +); + +-- PASO 3: CREAR TABLA GOOGLE_CALENDAR_EVENTS +CREATE TABLE IF NOT EXISTS google_calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, + google_event_id VARCHAR(255) UNIQUE NOT NULL, + title VARCHAR(500) NOT NULL, + description TEXT, + start_time_utc TIMESTAMPTZ NOT NULL, + end_time_utc TIMESTAMPTZ NOT NULL, + is_blocking BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- PASO 4: CREAR TABLA BOOKING_BLOCKS +CREATE TABLE IF NOT EXISTS booking_blocks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + location_id UUID NOT NULL REFERENCES locations(id) ON DELETE CASCADE, + resource_id UUID REFERENCES resources(id) ON DELETE CASCADE, + start_time_utc TIMESTAMPTZ NOT NULL, + end_time_utc TIMESTAMPTZ NOT NULL, + reason VARCHAR(500), + created_by UUID REFERENCES staff(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- PASO 5: CREAR INDICES +CREATE INDEX IF NOT EXISTS idx_staff_availability_staff_date ON staff_availability(staff_id, date); +CREATE INDEX IF NOT EXISTS idx_staff_availability_date ON staff_availability(date); +CREATE INDEX IF NOT EXISTS idx_google_calendar_staff ON google_calendar_events(staff_id); +CREATE INDEX IF NOT EXISTS idx_google_calendar_time ON google_calendar_events(start_time_utc, end_time_utc); +CREATE INDEX IF NOT EXISTS idx_booking_blocks_location ON booking_blocks(location_id); +CREATE INDEX IF NOT EXISTS idx_booking_blocks_time ON booking_blocks(start_time_utc, end_time_utc); +CREATE INDEX IF NOT EXISTS idx_booking_blocks_resource ON booking_blocks(resource_id); + +-- PASO 6: CREAR FUNCION DE VERIFICACION DE HORARIO LABORAL +CREATE OR REPLACE FUNCTION check_staff_work_hours( + p_staff_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ +) +RETURNS BOOLEAN AS $$ +DECLARE + v_staff_record RECORD; + v_start_time TIME; + v_end_time TIME; + v_work_days TEXT; + v_day_of_week INTEGER; +BEGIN + SELECT * INTO v_staff_record + FROM staff + WHERE id = p_staff_id; + + IF NOT FOUND THEN + RETURN false; + END IF; + + IF NOT v_staff_record.is_active THEN + RETURN false; + END IF; + + IF NOT v_staff_record.is_available_for_booking THEN + RETURN false; + END IF; + + v_start_time := p_start_time_utc::TIME; + v_end_time := p_end_time_utc::TIME; + v_work_days := v_staff_record.work_days; + + v_day_of_week := EXTRACT(ISODOW FROM p_start_time_utc); + + IF v_work_days IS NULL THEN + RETURN true; + END IF; + + IF v_day_of_week = 1 AND v_work_days LIKE '%MON%' THEN RETURN true; END IF; + IF v_day_of_week = 2 AND v_work_days LIKE '%TUE%' THEN RETURN true; END IF; + IF v_day_of_week = 3 AND v_work_days LIKE '%WED%' THEN RETURN true; END IF; + IF v_day_of_week = 4 AND v_work_days LIKE '%THU%' THEN RETURN true; END IF; + IF v_day_of_week = 5 AND v_work_days LIKE '%FRI%' THEN RETURN true; END IF; + IF v_day_of_week = 6 AND v_work_days LIKE '%SAT%' THEN RETURN true; END IF; + IF v_day_of_week = 7 AND v_work_days LIKE '%SUN%' THEN RETURN true; END IF; + + IF v_start_time < v_staff_record.work_hours_start OR + v_end_time > v_staff_record.work_hours_end THEN + RETURN false; + END IF; + + RETURN true; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 7: CREAR FUNCION DE VERIFICACION DE DISPONIBILIDAD DE STAFF +CREATE OR REPLACE FUNCTION check_staff_availability( + p_staff_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ, + p_exclude_booking_id UUID DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_is_work_hours BOOLEAN; + v_has_calendar_conflict BOOLEAN; + v_has_manual_block BOOLEAN; +BEGIN + v_is_work_hours := check_staff_work_hours(p_staff_id, p_start_time_utc, p_end_time_utc); + + IF NOT v_is_work_hours THEN + RETURN false; + END IF; + + SELECT EXISTS( + SELECT 1 + FROM google_calendar_events + WHERE staff_id = p_staff_id + AND is_blocking = true + AND NOT (p_end_time_utc <= start_time_utc OR p_start_time_utc >= end_time_utc) + ) INTO v_has_calendar_conflict; + + IF v_has_calendar_conflict THEN + RETURN false; + END IF; + + SELECT EXISTS( + SELECT 1 + FROM bookings + WHERE staff_id = p_staff_id + AND status NOT IN ('cancelled', 'no_show') + AND (p_exclude_booking_id IS NULL OR id != p_exclude_booking_id) + AND NOT (p_end_time_utc <= start_time_utc OR p_start_time_utc >= end_time_utc) + ) INTO v_has_manual_block; + + IF v_has_manual_block THEN + RETURN false; + END IF; + + SELECT EXISTS( + SELECT 1 + FROM staff_availability + WHERE staff_id = p_staff_id + AND date = p_start_time_utc::DATE + AND is_available = false + AND NOT (p_end_time_utc::TIME <= start_time OR p_start_time_utc::TIME >= end_time) + ) INTO v_has_manual_block; + + IF v_has_manual_block THEN + RETURN false; + END IF; + + RETURN true; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 8: CREAR FUNCION DE VERIFICACION DE DISPONIBILIDAD DE RECURSO +CREATE OR REPLACE FUNCTION check_resource_availability( + p_resource_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ, + p_exclude_booking_id UUID DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN NOT EXISTS( + SELECT 1 + FROM bookings + WHERE resource_id = p_resource_id + AND status NOT IN ('cancelled', 'no_show') + AND (p_exclude_booking_id IS NULL OR id != p_exclude_booking_id) + AND NOT (p_end_time_utc <= start_time_utc OR p_start_time_utc >= end_time_utc) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 9: CREAR FUNCION DE VERIFICACION DE BLOQUEOS DE LOCATION +CREATE OR REPLACE FUNCTION check_location_block( + p_location_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ +) +RETURNS BOOLEAN AS $$ +BEGIN + RETURN NOT EXISTS( + SELECT 1 + FROM booking_blocks + WHERE location_id = p_location_id + AND NOT (p_end_time_utc <= start_time_utc OR p_start_time_utc >= end_time_utc) + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 10: CREAR FUNCION PRINCIPAL DE VERIFICACION +CREATE OR REPLACE FUNCTION check_availability( + p_location_id UUID, + p_staff_id UUID, + p_resource_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ, + p_exclude_booking_id UUID DEFAULT NULL +) +RETURNS JSONB AS $$ +DECLARE + v_staff_available BOOLEAN; + v_resource_available BOOLEAN; + v_location_available BOOLEAN; + v_available BOOLEAN; + v_conflict_type VARCHAR(100); +BEGIN + v_staff_available := check_staff_availability(p_staff_id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id); + v_resource_available := check_resource_availability(p_resource_id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id); + v_location_available := NOT check_location_block(p_location_id, p_start_time_utc, p_end_time_utc); + + v_available := v_staff_available AND v_resource_available AND v_location_available; + + IF NOT v_available THEN + IF NOT v_staff_available THEN v_conflict_type := 'staff_not_available'; + ELSIF NOT v_resource_available THEN v_conflict_type := 'resource_not_available'; + ELSIF NOT v_location_available THEN v_conflict_type := 'location_blocked'; + END IF; + END IF; + + RETURN jsonb_build_object( + 'available', v_available, + 'staff_available', v_staff_available, + 'resource_available', v_resource_available, + 'location_available', v_location_available, + 'conflict_type', v_conflict_type + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 11: CREAR FUNCION PARA OBTENER STAFF DISPONIBLE +CREATE OR REPLACE FUNCTION get_available_staff( + p_location_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ, + p_service_id UUID DEFAULT NULL, + p_exclude_booking_id UUID DEFAULT NULL +) +RETURNS TABLE ( + staff_id UUID, + staff_name VARCHAR, + role VARCHAR, + work_hours_start TIME, + work_hours_end TIME, + work_days TEXT +) AS $$ +BEGIN + RETURN QUERY + SELECT + s.id, + s.display_name, + s.role, + s.work_hours_start, + s.work_hours_end, + s.work_days + FROM staff s + WHERE s.location_id = p_location_id + AND s.is_active = true + AND s.is_available_for_booking = true + AND s.role IN ('artist', 'staff', 'manager') + AND check_staff_availability(s.id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id) + ORDER BY + CASE s.role + WHEN 'manager' THEN 1 + WHEN 'staff' THEN 2 + WHEN 'artist' THEN 3 + END; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- PASO 12: CREAR FUNCION PARA OBTENER DISPONIBILIDAD DETALLADA +CREATE OR REPLACE FUNCTION get_detailed_availability( + p_location_id UUID, + p_service_id UUID, + p_date DATE, + p_time_slot_duration_minutes INTEGER DEFAULT 60 +) +RETURNS JSONB AS $$ +DECLARE + v_service_duration INTEGER; + v_start_time TIME := '09:00'::TIME; + v_end_time TIME := '21:00'::TIME; + v_time_slots JSONB; + v_slot_time TIMESTAMPTZ; + v_slot_end TIMESTAMPTZ; + v_count INTEGER; +BEGIN + SELECT duration_minutes INTO v_service_duration + FROM services + WHERE id = p_service_id; + + IF v_service_duration IS NULL THEN + v_service_duration := p_time_slot_duration_minutes; + END IF; + + FOR v_count IN 0..((EXTRACT(HOUR FROM v_end_time - v_start_time) * 60 + EXTRACT(MINUTE FROM v_end_time - v_start_time)) / v_service_duration LOOP + v_slot_time := p_date::TIMESTAMPTZ + v_start_time + (v_count * v_service_duration || ' minutes')::INTERVAL; + v_slot_end := v_slot_time + v_service_duration || ' minutes'::INTERVAL; + + SELECT jsonb_agg( + jsonb_build_object( + 'start_time', v_slot_time, + 'end_time', v_slot_end, + 'available_staff', ( + SELECT jsonb_agg( + jsonb_build_object( + 'staff_id', s.id, + 'name', s.display_name, + 'role', s.role + ) + ) + FROM staff s + WHERE s.location_id = p_location_id + AND s.is_active = true + AND s.is_available_for_booking = true + AND s.role IN ('artist', 'staff', 'manager') + AND check_staff_availability(s.id, v_slot_time, v_slot_end) + ) + ) + ) INTO v_time_slots + FROM staff + LIMIT 1; + END LOOP; +