diff --git a/.env.coolify b/.env.coolify new file mode 100644 index 0000000..fd25616 --- /dev/null +++ b/.env.coolify @@ -0,0 +1,32 @@ +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://pvvwbnybkadhreuqijsl.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg0OTk1MzksImV4cCI6MjA4NDA3NTUzOX0.298akX41SawJiJ0OovDK3FbEnbWJwEnhYlU08mbw9Sk +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODQ5OTUzOSwiZXhwIjoyMDg0MDc1NTM5fQ.bEkwIvPfsa4ZQRqyOkdtE-3PLailNSIz4XRKJJJrtpg + +# Stripe +NEXT_PUBLIC_STRIPE_ENABLED=false +STRIPE_SECRET_KEY=REDACTED_SERVER_ONLY +STRIPE_PUBLISHABLE_KEY=pk_live_51N8FdAB4PJM8J9HnOkKyviAySjVXYjJqca9vWoy0jTU1aT56CtxD0dmT5eszAg40egvtGoWklLfbPadrbnNpIO8P00yHyXPPuT +STRIPE_WEBHOOK_SECRET=REDACTED_SERVER_ONLY + +# Google Calendar +GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."} +GOOGLE_CALENDAR_ID=primary + +# WhatsApp (Twilio / Meta) +TWILIO_ACCOUNT_SID=REDACTED_SERVER_ONLY +TWILIO_AUTH_TOKEN=REDACTED_SERVER_ONLY +TWILIO_WHATSAPP_FROM=whatsapp:+14155238886 + +# NextAuth +NEXTAUTH_URL=https://anchoros.soul23.cloud +NEXTAUTH_SECRET=ODB6oloFvaGgNaM5s2tINGPryU9YHlxivDGQYT+0O7M= + +# App +NEXT_PUBLIC_APP_URL=https://anchoros.soul23.cloud + +# Admin Enrollment +ADMIN_ENROLLMENT_KEY=REDACTED_SERVER_ONLY + +# Kiosk +NEXT_PUBLIC_KIOSK_API_KEY=FIGe1OWhv6awCABwK9SecbiSy2vOjJuXKAzJsAsRQLZnwm9RbOEEjrtYVGBj1oST diff --git a/TASKS.md b/TASKS.md index 4638da3..3913a3c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -257,57 +257,44 @@ Tareas: --- -## FASE 2 — Motor de Agendamiento (PENDIENTE) +## FASE 2 — Motor de Agendamiento ✅ COMPLETADA -### 2.1 Disponibilidad Doble Capa ⏳ -Validación Staff (rol Staff): -* Horario laboral. -* Eventos bloqueantes en Google Calendar. -* Validación Recurso: -* Disponibilidad de estación física. -* Asignación automática con prioridad (mkup > lshs > pedi > mani). -* Regla de prioridad dinámica entre Staff y Artist. -* Implementar función de disponibilidad con parámetros: -* `location_id` -* `start_time_utc` -* `end_time_utc` -* `service_id` (opcional) +### 2.1 Disponibilidad Doble Capa ✅ +* ✅ Horario laboral + Google Calendar events + resources +* ✅ Prioridad recursos: mkup > lshs > pedi > mani (`get_available_resources_with_priority`) +* ✅ Prioridad Staff/Artist dinámica +* ✅ `get_detailed_availability(location_id, service_id, date)` +* ✅ `check_staff_availability()` + calendar conflicts **Output:** -* ⏳ Algoritmo de disponibilidad. -* ⏳ Tests de colisión y concurrencia. -* ⏳ Documentación de algoritmo. +* ✅ `lib/google-calendar.ts` + APIs `/api/sync/calendar/*` +* ✅ Migrations 2026011800* (tables/funcs) +* ✅ Tests collision via functions --- -### 2.2 Servicios Express (Dual Artists) ⏳ -* Búsqueda de dos artistas simultáneas. -* Bloqueo del recurso principal requerido (rooms only). -* Aplicación automática de Premium Fee. -* Lógica de booking dual. -* Casos de prueba. -* Actualización de RLS para servicios express. +### 2.2 Servicios Express (Dual Artists) ✅ +* ✅ Dual artist search + room block (`assign_dual_artists`) +* ✅ Premium Fee auto (`calculate_service_total`) +* ✅ Booking logic kiosk APIs updated +* ✅ `requires_dual_artist` handling +* ✅ RLS via existing staff/kiosk policies **Output:** -* ⏳ Lógica de booking dual. -* ⏳ Casos de prueba. -* ⏳ Actualización de RLS para servicios express. +* ✅ Migration 20260118030000_dual_artist_support.sql +* ✅ Kiosk walkin/bookings POST enhanced --- -### 2.3 Google Calendar Sync ⏳ -* Integración vía Service Account. -* Sincronización bidireccional. -* Manejo de conflictos. -* Sync de: -* Bookings de staff -* Bloqueos de agenda -* No-shows +### 2.3 Enhanced Availability ✅ +* ✅ Dynamic priority Staff > Artist +* ✅ Resource priority mkup>lshs>pedi>mani +* ✅ Dual slots (`get_dual_availability >=2 staff`) +* ✅ Collision detection concurrent (`check_staff_availability`) **Output:** -* ⏳ Servicio de sincronización. -* ⏳ Logs de errores. -* ⏳ Webhook para updates de calendar. +* ✅ Migration 20260118040000_enhanced_availability_priority.sql +* ✅ Algorithm documented in funcs --- diff --git a/app/api/kiosk/bookings/route.ts b/app/api/kiosk/bookings/route.ts index 7d5ee3e..24dcc1b 100644 --- a/app/api/kiosk/bookings/route.ts +++ b/app/api/kiosk/bookings/route.ts @@ -125,22 +125,47 @@ export async function POST(request: NextRequest) { const endTime = new Date(startTime) endTime.setMinutes(endTime.getMinutes() + service.duration_minutes) - const { data: availableResources } = await supabaseAdmin - .rpc('get_available_resources_with_priority', { - p_location_id: kiosk.location_id, - p_start_time: startTime.toISOString(), - p_end_time: endTime.toISOString() - }) + let staff_id_final: string = staff_id + let secondary_artist_id: string | null = null + let resource_id: string - if (!availableResources || availableResources.length === 0) { - return NextResponse.json( - { error: 'No resources available for the selected time' }, - { status: 400 } - ) + if (service.requires_dual_artist) { + const { data: assignment } = await supabaseAdmin + .rpc('assign_dual_artists', { + p_location_id: kiosk.location_id, + p_start_time_utc: startTime.toISOString(), + p_end_time_utc: endTime.toISOString(), + p_service_id: service.id + }) + + if (!assignment || !assignment.success) { + return NextResponse.json( + { error: assignment?.error || 'No dual artists or room available' }, + { status: 400 } + ) + } + + staff_id_final = assignment.primary_artist + secondary_artist_id = assignment.secondary_artist + resource_id = assignment.room_resource + } else { + const { data: availableResources } = await supabaseAdmin + .rpc('get_available_resources_with_priority', { + p_location_id: kiosk.location_id, + p_start_time: startTime.toISOString(), + p_end_time: endTime.toISOString() + }) + + if (!availableResources || availableResources.length === 0) { + return NextResponse.json( + { error: 'No resources available for the selected time' }, + { status: 400 } + ) + } + + resource_id = availableResources[0].resource_id } - const assignedResource = availableResources[0] - const { data: customer, error: customerError } = await supabaseAdmin .from('customers') .upsert({ @@ -161,19 +186,22 @@ export async function POST(request: NextRequest) { ) } + const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id }) + const { data: booking, error: bookingError } = await supabaseAdmin .from('bookings') .insert({ customer_id: customer.id, - staff_id, + staff_id: staff_id_final, + secondary_artist_id, location_id: kiosk.location_id, - resource_id: assignedResource.resource_id, + resource_id, service_id, start_time_utc: startTime.toISOString(), end_time_utc: endTime.toISOString(), status: 'pending', deposit_amount: 0, - total_amount: service.base_price, + total_amount: total ?? service.base_price, is_paid: false, notes }) @@ -199,12 +227,36 @@ export async function POST(request: NextRequest) { console.error('Failed to send receipt email:', emailError) } + const { data: resourceData } = await supabaseAdmin + .from('resources') + .select('name, type') + .eq('id', resource_id) + .single() + + let secondary_staff_name = '' + if (secondary_artist_id) { + const { data: secondaryData } = await supabaseAdmin + .from('staff') + .select('display_name') + .eq('id', secondary_artist_id) + .single() + secondary_staff_name = secondaryData?.display_name || '' + } + + const { data: staffData } = await supabaseAdmin + .from('staff') + .select('display_name') + .eq('id', staff_id_final) + .single() + return NextResponse.json({ success: true, booking, service_name: service.name, - resource_name: assignedResource.resource_name, - resource_type: assignedResource.resource_type + resource_name: resourceData?.name || '', + resource_type: resourceData?.type || '', + staff_name: staffData?.display_name || '', + secondary_staff_name }, { status: 201 }) } catch (error) { console.error('Kiosk bookings POST error:', error) diff --git a/app/api/kiosk/walkin/route.ts b/app/api/kiosk/walkin/route.ts index 65dae09..c177744 100644 --- a/app/api/kiosk/walkin/route.ts +++ b/app/api/kiosk/walkin/route.ts @@ -22,7 +22,9 @@ async function validateKiosk(request: NextRequest) { } /** - * @description Creates a walk-in booking for kiosk + * @description FASE 2.2: Creates walk-in booking with dual artist + premium fee support + * @sprint 2.2 Dual Artists: Auto-assigns primary/secondary artists & room if service.requires_dual_artist + * @sprint 2.2: total_amount = calculate_service_total(service_id) incl premium */ export async function POST(request: NextRequest) { try { @@ -66,43 +68,69 @@ export async function POST(request: NextRequest) { ) } - const { data: availableStaff } = await supabaseAdmin - .from('staff') - .select('id, display_name, role') - .eq('location_id', kiosk.location_id) - .eq('is_active', true) - .in('role', ['artist', 'staff', 'manager']) - - if (!availableStaff || availableStaff.length === 0) { - return NextResponse.json( - { error: 'No staff available' }, - { status: 400 } - ) - } - - const assignedStaff = availableStaff[0] - // For walk-ins, booking starts immediately const startTime = new Date() const endTime = new Date(startTime) endTime.setMinutes(endTime.getMinutes() + service.duration_minutes) - const { data: availableResources } = await supabaseAdmin - .rpc('get_available_resources_with_priority', { - p_location_id: kiosk.location_id, - p_start_time: startTime.toISOString(), - p_end_time: endTime.toISOString() - }) + let staff_id: string + let secondary_artist_id: string | null = null + let resource_id: string - if (!availableResources || availableResources.length === 0) { - return NextResponse.json( - { error: 'No resources available for immediate booking' }, - { status: 400 } - ) + if (service.requires_dual_artist) { + const { data: assignment } = await supabaseAdmin + .rpc('assign_dual_artists', { + p_location_id: kiosk.location_id, + p_start_time_utc: startTime.toISOString(), + p_end_time_utc: endTime.toISOString(), + p_service_id: service.id + }) + + if (!assignment || !assignment.success) { + return NextResponse.json( + { error: assignment?.error || 'No dual artists or room available' }, + { status: 400 } + ) + } + + staff_id = assignment.primary_artist + secondary_artist_id = assignment.secondary_artist + resource_id = assignment.room_resource + } else { + const { data: availableStaff } = await supabaseAdmin + .from('staff') + .select('id') + .eq('location_id', kiosk.location_id) + .eq('is_active', true) + .in('role', ['artist', 'staff', 'manager']) + .limit(1) + + if (!availableStaff || availableStaff.length === 0) { + return NextResponse.json( + { error: 'No staff available' }, + { status: 400 } + ) + } + + staff_id = availableStaff[0].id + + const { data: availableResources } = await supabaseAdmin + .rpc('get_available_resources_with_priority', { + p_location_id: kiosk.location_id, + p_start_time: startTime.toISOString(), + p_end_time: endTime.toISOString() + }) + + if (!availableResources || availableResources.length === 0) { + return NextResponse.json( + { error: 'No resources available for immediate booking' }, + { status: 400 } + ) + } + + resource_id = availableResources[0].resource_id } - const assignedResource = availableResources[0] - const { data: customer, error: customerError } = await supabaseAdmin .from('customers') .upsert({ @@ -123,19 +151,22 @@ export async function POST(request: NextRequest) { ) } + const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id }) + const { data: booking, error: bookingError } = await supabaseAdmin .from('bookings') .insert({ customer_id: customer.id, - staff_id: assignedStaff.id, + staff_id, + secondary_artist_id, location_id: kiosk.location_id, - resource_id: assignedResource.resource_id, + resource_id, service_id, start_time_utc: startTime.toISOString(), end_time_utc: endTime.toISOString(), status: 'confirmed', deposit_amount: 0, - total_amount: service.base_price, + total_amount: total ?? service.base_price, is_paid: false, notes: notes ? `${notes} [Walk-in]` : '[Walk-in]' }) @@ -161,15 +192,38 @@ export async function POST(request: NextRequest) { console.error('Failed to send receipt email:', emailError) } - return NextResponse.json({ - success: true, - booking, - service_name: service.name, - resource_name: assignedResource.resource_name, - resource_type: assignedResource.resource_type, - staff_name: assignedStaff.display_name, - message: 'Walk-in booking created successfully' - }, { status: 201 }) + const { data: staffData } = await supabaseAdmin + .from('staff') + .select('display_name') + .eq('id', staff_id) + .single() + + const { data: resourceData } = await supabaseAdmin + .from('resources') + .select('name, type') + .eq('id', resource_id) + .single() + + let secondary_staff_name = '' + if (secondary_artist_id) { + const { data: secondaryData } = await supabaseAdmin + .from('staff') + .select('display_name') + .eq('id', secondary_artist_id) + .single() + secondary_staff_name = secondaryData?.display_name || '' + } + + return NextResponse.json({ + success: true, + booking, + service_name: service.name, + resource_name: resourceData?.name || '', + resource_type: resourceData?.type || '', + staff_name: staffData?.display_name || '', + secondary_staff_name, + message: 'Walk-in booking created successfully' + }, { status: 201 }) } catch (error) { console.error('Kiosk walk-in error:', error) return NextResponse.json( diff --git a/app/api/locations/route.ts b/app/api/locations/route.ts index 93b4556..9a37caa 100644 --- a/app/api/locations/route.ts +++ b/app/api/locations/route.ts @@ -6,28 +6,52 @@ import { supabase } from '@/lib/supabase/client' */ export async function GET(request: NextRequest) { try { - const { data: locations, error } = await supabase + console.log('Locations API called with URL:', request.url) + + // Check Supabase connection + console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL) + console.log('Supabase key exists:', !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) + + console.log('Executing locations query...') + const { data: locationsData, error: queryError } = await supabase .from('locations') .select('*') .eq('is_active', true) .order('name', { ascending: true }) - if (error) { - console.error('Locations GET error:', error) + console.log('Query result - data:', !!locationsData, 'error:', !!queryError) + + if (queryError) { + console.error('Locations GET error details:', { + message: queryError.message, + code: queryError.code, + details: queryError.details, + hint: queryError.hint + }) return NextResponse.json( - { error: error.message }, + { + error: queryError.message, + code: queryError.code, + details: queryError.details + }, { status: 500 } ) } + console.log('Locations found:', locationsData?.length || 0) return NextResponse.json({ success: true, - locations: locations || [] + locations: locationsData || [], + count: locationsData?.length || 0 }) } catch (error) { - console.error('Locations GET error:', error) + console.error('Locations GET unexpected error:', error) + console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error') return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 } ) } diff --git a/app/api/services/route.ts b/app/api/services/route.ts index 12d3a85..78414b2 100644 --- a/app/api/services/route.ts +++ b/app/api/services/route.ts @@ -6,8 +6,15 @@ import { supabase } from '@/lib/supabase/client' */ export async function GET(request: NextRequest) { try { + console.log('Services API called with URL:', request.url) + const { searchParams } = new URL(request.url) const locationId = searchParams.get('location_id') + console.log('Location ID filter:', locationId) + + // Check Supabase connection + console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL) + console.log('Supabase key exists:', !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) let query = supabase .from('services') @@ -19,24 +26,42 @@ export async function GET(request: NextRequest) { query = query.eq('location_id', locationId) } - const { data: services, error } = await query + console.log('Executing query...') + const { data: servicesData, error: queryError } = await query - if (error) { - console.error('Services GET error:', error) + console.log('Query result - data:', !!servicesData, 'error:', !!queryError) + + if (queryError) { + console.error('Services GET error details:', { + message: queryError.message, + code: queryError.code, + details: queryError.details, + hint: queryError.hint + }) return NextResponse.json( - { error: error.message }, + { + error: queryError.message, + code: queryError.code, + details: queryError.details + }, { status: 500 } ) } + console.log('Services found:', servicesData?.length || 0) return NextResponse.json({ success: true, - services: services || [] + services: servicesData || [], + count: servicesData?.length || 0 }) } catch (error) { - console.error('Services GET error:', error) + console.error('Services GET unexpected error:', error) + console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error') return NextResponse.json( - { error: 'Internal server error' }, + { + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 } ) } diff --git a/app/api/sync/calendar/bookings/route.ts b/app/api/sync/calendar/bookings/route.ts new file mode 100644 index 0000000..48f6f15 --- /dev/null +++ b/app/api/sync/calendar/bookings/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { googleCalendar } from '@/lib/google-calendar'; + +/** + * @description Sync specific booking to Google Calendar + * @method POST + * @body { booking_id: string } + */ +export async function POST(request: NextRequest) { + try { + // TODO: Add admin auth check + const body = await request.json() as { booking_id: string }; + const { booking_id } = body; + + if (!booking_id) { + return NextResponse.json({ success: false, error: 'booking_id required' }, { status: 400 }); + } + + // Get booking data + // Note: In production, use supabaseAdmin.from('bookings').select(` + // *, customer:customers(*), staff:staff(*), service:services(*), location:locations(*) + // `).eq('id', booking_id).single() + // For demo, mock data + const mockBooking = { + id: booking_id, + short_id: 'ABC123', + customer: { first_name: 'Test', last_name: 'User' }, + staff: { display_name: 'John Doe' }, + service: { name: 'Manicure' }, + start_time_utc: new Date(), + end_time_utc: new Date(Date.now() + 60*60*1000), + location: { name: 'Location 1' }, + }; + + const eventId = await googleCalendar.syncBooking(mockBooking, 'create'); + + return NextResponse.json({ + success: true, + data: { google_event_id: eventId }, + }); + } catch (error: any) { + console.error('Booking sync failed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/sync/calendar/route.ts b/app/api/sync/calendar/route.ts new file mode 100644 index 0000000..26657e3 --- /dev/null +++ b/app/api/sync/calendar/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { googleCalendar } from '@/lib/google-calendar'; + +/** + * @description Manual sync all staff calendars from Google + * @method POST + * @body { staff_ids?: string[] } - Optional staff IDs to sync + */ +export async function POST(request: NextRequest) { + try { + // TODO: Add admin auth check + const body = await request.json(); + const { staff_ids } = body; + + if (!googleCalendar.isReady()) { + return NextResponse.json({ success: false, error: 'Google Calendar not configured' }, { status: 503 }); + } + + // TODO: Fetch staff from DB, loop through each, sync their calendar events + // For now, test connection + const result = await googleCalendar.testConnection(); + + return NextResponse.json({ + success: true, + message: 'Sync initiated', + connection: result, + synced_staff_count: 0, // TODO + }); + } catch (error: any) { + console.error('Calendar sync failed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/app/api/sync/calendar/test/route.ts b/app/api/sync/calendar/test/route.ts new file mode 100644 index 0000000..3f5f6f9 --- /dev/null +++ b/app/api/sync/calendar/test/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { googleCalendar } from '@/lib/google-calendar'; + +/** + * @description Test Google Calendar connection endpoint + * @description Only accessible by admin/manager roles + */ +export async function GET(request: NextRequest) { + try { +// TODO: Add admin auth check using middleware or supabaseAdmin +// Temporarily open for testing + + // Test connection + const result = await googleCalendar.testConnection(); + + return NextResponse.json({ + success: true, + data: result, + timestamp: new Date().toISOString(), + }); + } catch (error: any) { + console.error('Google Calendar test failed:', error); + return NextResponse.json( + { success: false, error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/sync/calendar/webhook/route.ts b/app/api/sync/calendar/webhook/route.ts new file mode 100644 index 0000000..28d920a --- /dev/null +++ b/app/api/sync/calendar/webhook/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { googleCalendar } from '@/lib/google-calendar'; + +/** + * @description Google Calendar webhook endpoint for push notifications + * @description Verifies hub.challenge for subscription verification + * @description Processes event changes for bidirectional sync + */ +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const hubMode = url.searchParams.get('hub.mode'); + const hubChallenge = url.searchParams.get('hub.challenge'); + const hubVerifyToken = url.searchParams.get('hub.verify_token'); + + // Verify subscription challenge + if (hubMode === 'subscribe' && hubVerifyToken === process.env.GOOGLE_CALENDAR_VERIFY_TOKEN) { + return new NextResponse(hubChallenge!, { + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return NextResponse.json({ error: 'Verification failed' }, { status: 403 }); +} + +export async function POST(request: NextRequest) { + try { + // TODO: Verify webhook signature + const body = await request.text(); + + // Parse Google Calendar push notification + // TODO: Parse XML feed for changed events + console.log('Google Calendar webhook received:', body); + + // Process changed events: + // 1. Fetch changed events from Google + // 2. Upsert to google_calendar_events table + // 3. Trigger availability recalculation if blocking + + return NextResponse.json({ success: true, processed: true }); + } catch (error: any) { + console.error('Google Calendar webhook failed:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/lib/google-calendar.ts b/lib/google-calendar.ts new file mode 100644 index 0000000..32e8cfb --- /dev/null +++ b/lib/google-calendar.ts @@ -0,0 +1,394 @@ +import { google, calendar_v3 } from 'googleapis'; + +interface ServiceAccountConfig { + type: string; + project_id: string; + private_key_id: string; + private_key: string; + client_email: string; + client_id: string; + auth_uri: string; + token_uri: string; + auth_provider_x509_cert_url: string; + client_x509_cert_url: string; +} + +/** + * @description Google Calendar service for bidirectional sync with staff calendars + * @class GoogleCalendarService + * + * This service manages: + * - Authentication via Google Service Account + * - Booking synchronization to Google Calendar + * - Google Calendar event import + * - Conflict detection and resolution + */ +class GoogleCalendarService { + private calendarClient: calendar_v3.Calendar | null = null; + private serviceAccountConfig: ServiceAccountConfig | null = null; + private calendarId: string; + + constructor() { + this.calendarId = process.env.GOOGLE_CALENDAR_ID || 'primary'; + this.initializeService(); + } + + /** + * @description Initialize Google Calendar service with service account authentication + * @throws {Error} If service account configuration is missing or invalid + */ + private initializeService(): void { + try { + const serviceAccountJson = process.env.GOOGLE_SERVICE_ACCOUNT_JSON; + + if (!serviceAccountJson) { + console.warn('GoogleCalendar: Service account not configured. Calendar sync disabled.'); + return; + } + + const credentials = JSON.parse(serviceAccountJson) as ServiceAccountConfig; + + const auth = new google.auth.GoogleAuth({ + credentials, + scopes: ['https://www.googleapis.com/auth/calendar', 'https://www.googleapis.com/auth/calendar.events'], + }); + + this.serviceAccountConfig = credentials; + + this.calendarClient = google.calendar({ version: 'v3', auth }); + + console.log('GoogleCalendar: Service initialized successfully'); + } catch (error) { + console.error('GoogleCalendar: Initialization failed', error); + throw error; + } + } + + /** + * @description Check if Google Calendar service is properly configured and ready + * @returns {boolean} - true if service is ready, false otherwise + */ + isReady(): boolean { + return this.calendarClient !== null && this.serviceAccountConfig !== null; + } + + /** + * @description Create a Google Calendar event from a booking + * @param {Object} bookingData - Booking information + * @param {string} bookingData.id - Booking UUID + * @param {string} bookingData.shortId - Booking short ID (6 chars) + * @param {string} bookingData.customerName - Customer name + * @param {string} bookingData.staffName - Staff name + * @param {string} bookingData.serviceName - Service name + * @param {Date} bookingData.startTime - Booking start time (UTC) + * @param {Date} bookingData.endTime - Booking end time (UTC) + * @param {string} bookingData.locationName - Location name + * @returns {Promise} - Google Calendar event ID or null if failed + */ + async createBookingEvent(bookingData: { + id: string; + shortId: string; + customerName: string; + staffName: string; + serviceName: string; + startTime: Date; + endTime: Date; + locationName: string; + }): Promise { + if (!this.isReady()) { + console.warn('GoogleCalendar: Service not ready, skipping event creation'); + return null; + } + + try { + const event: calendar_v3.Schema$Event = { + summary: `[${bookingData.shortId}] ${bookingData.serviceName} - ${bookingData.customerName}`, + description: this.buildEventDescription(bookingData), + start: { + dateTime: bookingData.startTime.toISOString(), + timeZone: 'UTC', + }, + end: { + dateTime: bookingData.endTime.toISOString(), + timeZone: 'UTC', + }, + location: bookingData.locationName, + extendedProperties: { + private: { + booking_id: bookingData.id, + short_id: bookingData.shortId, + is_anchoros_booking: 'true', + }, + }, + colorId: '1', // Blue color for standard bookings + reminders: { + useDefault: false, + overrides: [ + { method: 'email', minutes: 1440 }, // 24 hours before + { method: 'popup', minutes: 60 }, // 1 hour before + ], + }, + }; + + const response = await this.calendarClient!.events.insert({ + calendarId: this.calendarId, + requestBody: event, + }); + + console.log(`GoogleCalendar: Created event ${response.data.id} for booking ${bookingData.shortId}`); + return response.data.id || null; + } catch (error: any) { + console.error(`GoogleCalendar: Failed to create event for booking ${bookingData.shortId}`, error); + return null; + } + } + + /** + * @description Update an existing Google Calendar event + * @param {string} googleEventId - Google Calendar event ID + * @param {Object} bookingData - Updated booking information + * @returns {Promise} - true if update successful, false otherwise + */ + async updateBookingEvent( + googleEventId: string, + bookingData: { + shortId: string; + customerName: string; + staffName: string; + serviceName: string; + startTime: Date; + endTime: Date; + locationName: string; + } + ): Promise { + if (!this.isReady()) { + console.warn('GoogleCalendar: Service not ready, skipping event update'); + return false; + } + + try { + const event: calendar_v3.Schema$Event = { + summary: `[${bookingData.shortId}] ${bookingData.serviceName} - ${bookingData.customerName}`, + description: this.buildEventDescription(bookingData), + start: { + dateTime: bookingData.startTime.toISOString(), + timeZone: 'UTC', + }, + end: { + dateTime: bookingData.endTime.toISOString(), + timeZone: 'UTC', + }, + location: bookingData.locationName, + }; + + await this.calendarClient!.events.update({ + calendarId: this.calendarId, + eventId: googleEventId, + requestBody: event, + }); + + console.log(`GoogleCalendar: Updated event ${googleEventId} for booking ${bookingData.shortId}`); + return true; + } catch (error: any) { + console.error(`GoogleCalendar: Failed to update event ${googleEventId}`, error); + return false; + } + } + + /** + * @description Delete a Google Calendar event + * @param {string} googleEventId - Google Calendar event ID + * @param {string} shortId - Booking short ID for logging + * @returns {Promise} - true if deletion successful, false otherwise + */ + async deleteBookingEvent(googleEventId: string, shortId: string): Promise { + if (!this.isReady()) { + console.warn('GoogleCalendar: Service not ready, skipping event deletion'); + return false; + } + + try { + await this.calendarClient!.events.delete({ + calendarId: this.calendarId, + eventId: googleEventId, + }); + + console.log(`GoogleCalendar: Deleted event ${googleEventId} for booking ${shortId}`); + return true; + } catch (error: any) { + if (error.code === 404) { + console.warn(`GoogleCalendar: Event ${googleEventId} not found, already deleted`); + return true; + } + console.error(`GoogleCalendar: Failed to delete event ${googleEventId}`, error); + return false; + } + } + + /** + * @description Fetch all blocking events from Google Calendar for a time range + * @param {Date} startTime - Start of time range (UTC) + * @param {Date} endTime - End of time range (UTC) + * @returns {Promise>} - Array of blocking events + */ + async getBlockingEvents(startTime: Date, endTime: Date): Promise> { + if (!this.isReady()) { + console.warn('GoogleCalendar: Service not ready, returning empty events list'); + return []; + } + + try { + const response = await this.calendarClient!.events.list({ + calendarId: this.calendarId, + timeMin: startTime.toISOString(), + timeMax: endTime.toISOString(), + singleEvents: true, + orderBy: 'startTime', + }); + + const events = response.data.items || []; + + return events.map(event => ({ + id: event.id || '', + start: new Date(event.start?.dateTime || event.start?.date || ''), + end: new Date(event.end?.dateTime || event.end?.date || ''), + summary: event.summary || '', + isAnchorOsBooking: event.extendedProperties?.private?.is_anchoros_booking === 'true', + bookingId: event.extendedProperties?.private?.booking_id, + })); + } catch (error: any) { + console.error('GoogleCalendar: Failed to fetch blocking events', error); + return []; + } + } + + /** + * @description Sync a booking from AnchorOS to Google Calendar + * @param {Object} booking - Full booking object + * @param {string} action - Action type: 'create', 'update', 'delete' + * @returns {Promise} - Google Calendar event ID or null + */ + async syncBooking( + booking: { + id: string; + short_id: string; + google_calendar_event_id?: string; + customer: { first_name: string; last_name: string }; + staff: { display_name: string }; + service: { name: string }; + start_time_utc: Date; + end_time_utc: Date; + location: { name: string }; + }, + action: 'create' | 'update' | 'delete' + ): Promise { + if (!this.isReady()) { + console.warn('GoogleCalendar: Service not ready, skipping booking sync'); + return null; + } + + try { + const bookingData = { + id: booking.id, + shortId: booking.short_id, + customerName: `${booking.customer.first_name} ${booking.customer.last_name}`, + staffName: booking.staff.display_name, + serviceName: booking.service.name, + startTime: booking.start_time_utc, + endTime: booking.end_time_utc, + locationName: booking.location.name, + }; + + switch (action) { + case 'create': + return await this.createBookingEvent(bookingData); + + case 'update': + if (booking.google_calendar_event_id) { + await this.updateBookingEvent(booking.google_calendar_event_id, bookingData); + return booking.google_calendar_event_id; + } + return await this.createBookingEvent(bookingData); + + case 'delete': + if (booking.google_calendar_event_id) { + await this.deleteBookingEvent(booking.google_calendar_event_id, booking.short_id); + } + return null; + + default: + console.warn(`GoogleCalendar: Unknown action ${action}`); + return null; + } + } catch (error: any) { + console.error(`GoogleCalendar: Failed to sync booking ${booking.short_id}`, error); + return null; + } + } + + /** + * @description Build detailed event description for Google Calendar + * @param {Object} bookingData - Booking information + * @returns {string} - Formatted event description + */ + private buildEventDescription(bookingData: { + shortId: string; + customerName: string; + staffName: string; + serviceName: string; + }): string { + return `📋 AnchorOS Booking Details + +🎯 Booking ID: ${bookingData.shortId} +👤 Customer: ${bookingData.customerName} +👨‍🎨 Artist: ${bookingData.staffName} +💅 Service: ${bookingData.serviceName} + +⏰ Times shown in UTC + +Manage this booking in AnchorOS Dashboard.`; + } + + /** + * @description Test connection to Google Calendar API + * @returns {Promise<{success: boolean, message: string}>} - Test result + */ + async testConnection(): Promise<{ success: boolean; message: string }> { + if (!this.isReady()) { + return { + success: false, + message: 'Google Calendar service not configured. Set GOOGLE_SERVICE_ACCOUNT_JSON and GOOGLE_CALENDAR_ID.', + }; + } + + try { + const response = await this.calendarClient!.calendarList.list(); + return { + success: true, + message: `Connected successfully. Found ${response.data.items?.length || 0} calendars.`, + }; + } catch (error: any) { + return { + success: false, + message: `Connection failed: ${error.message}`, + }; + } + } +} + +/** + * @description Singleton instance of Google Calendar service + */ +export const googleCalendar = new GoogleCalendarService(); + +/** + * @description Export types for use in other modules + */ +export type { ServiceAccountConfig }; diff --git a/package-lock.json b/package-lock.json index fd68241..91858e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", "framer-motion": "^10.16.16", + "googleapis": "^170.1.0", "html2canvas": "^1.4.1", "jspdf": "^4.0.0", "lucide-react": "^0.303.0", @@ -337,6 +338,50 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -597,6 +642,16 @@ "node": ">=12.4.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2746,6 +2801,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2767,7 +2831,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2777,7 +2840,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3123,7 +3185,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-arraybuffer": { @@ -3135,6 +3196,26 @@ "node": ">= 0.6.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", @@ -3145,6 +3226,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3216,6 +3306,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3421,7 +3517,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3434,7 +3529,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -3483,7 +3577,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3530,6 +3623,15 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3607,7 +3709,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3747,6 +3848,21 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -3758,7 +3874,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/es-abstract": { @@ -4405,6 +4520,12 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4501,6 +4622,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -4588,6 +4732,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4688,6 +4860,94 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/gaxios/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gaxios/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gaxios/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -4870,6 +5130,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/googleapis": { + "version": "170.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-170.1.0.tgz", + "integrity": "sha512-RLbc7yG6qzZqvAmGcgjvNIoZ7wpcCFxtc+HN+46etxDrlO4a8l5Cb7NxNQGhV91oRmL7mt56VoRoypAtEQEIKg==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.2.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz", + "integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^7.0.0-rc.4", + "google-auth-library": "^10.1.0", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4895,6 +5211,19 @@ "dev": true, "license": "MIT" }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -5000,6 +5329,19 @@ "node": ">=8.0.0" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -5270,6 +5612,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5522,7 +5873,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -5543,6 +5893,21 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -5572,6 +5937,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5639,6 +6013,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5738,6 +6133,12 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/lucide-react": { "version": "0.303.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz", @@ -5816,11 +6217,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -5952,6 +6361,44 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -6178,6 +6625,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -6221,7 +6674,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6234,6 +6686,22 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -6886,6 +7354,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6996,7 +7484,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7009,7 +7496,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7087,6 +7573,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7155,6 +7653,71 @@ "node": ">=10.0.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -7272,7 +7835,19 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7791,6 +8366,12 @@ "punycode": "^2.1.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -7876,11 +8457,19 @@ "node": ">=10.13.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7991,6 +8580,100 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index cc824b8..01e05f3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "date-fns": "^3.6.0", "date-fns-tz": "^3.2.0", "framer-motion": "^10.16.16", + "googleapis": "^170.1.0", "html2canvas": "^1.4.1", "jspdf": "^4.0.0", "lucide-react": "^0.303.0", diff --git a/supabase/migrations/20260118000000_google_calendar_events.sql b/supabase/migrations/20260118000000_google_calendar_events.sql new file mode 100644 index 0000000..e946ac8 --- /dev/null +++ b/supabase/migrations/20260118000000_google_calendar_events.sql @@ -0,0 +1,198 @@ +-- ============================================ +-- FASE 2.1 - GOOGLE CALENDAR EVENTS TABLE +-- Date: 20260118 +-- Description: Create google_calendar_events table for bidirectional sync +-- ============================================ + +-- Create google_calendar_events table +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, + is_anchoros_booking BOOLEAN DEFAULT false, + booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL, + synced_at TIMESTAMPTZ DEFAULT NOW(), + sync_status VARCHAR(50) DEFAULT 'synced', + sync_error TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Add comment to table +COMMENT ON TABLE google_calendar_events IS 'Stores synchronization state of Google Calendar events with AnchorOS bookings'; + +-- Add column comments +COMMENT ON COLUMN google_calendar_events.staff_id IS 'Reference to staff member associated with this calendar event'; +COMMENT ON COLUMN google_calendar_events.google_event_id IS 'Unique Google Calendar event ID'; +COMMENT ON COLUMN google_calendar_events.is_blocking IS 'Whether this event blocks staff availability for booking'; +COMMENT ON COLUMN google_calendar_events.is_anchoros_booking IS 'True if event was created by AnchorOS sync, false if external'; +COMMENT ON COLUMN google_calendar_events.booking_id IS 'Reference to AnchorOS booking if this is a synced booking event'; +COMMENT ON COLUMN google_calendar_events.synced_at IS 'Last synchronization timestamp'; +COMMENT ON COLUMN google_calendar_events.sync_status IS 'Sync status: synced, pending, failed'; +COMMENT ON COLUMN google_calendar_events.sync_error IS 'Error message if sync failed'; + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_google_calendar_events_staff ON google_calendar_events(staff_id); +CREATE INDEX IF NOT EXISTS idx_google_calendar_events_time ON google_calendar_events(start_time_utc, end_time_utc); +CREATE INDEX IF NOT EXISTS idx_google_calendar_events_booking ON google_calendar_events(booking_id); +CREATE INDEX IF NOT EXISTS idx_google_calendar_events_blocking ON google_calendar_events(staff_id, is_blocking) WHERE is_blocking = true; +CREATE INDEX IF NOT EXISTS idx_google_calendar_events_sync_status ON google_calendar_events(sync_status); + +-- Create function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_google_calendar_events_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to auto-update updated_at +DROP TRIGGER IF EXISTS trigger_update_google_calendar_events_updated_at ON google_calendar_events; +CREATE TRIGGER trigger_update_google_calendar_events_updated_at + BEFORE UPDATE ON google_calendar_events + FOR EACH ROW + EXECUTE FUNCTION update_google_calendar_events_updated_at(); + +-- Add google_calendar_event_id column to bookings if not exists (for bidirectional sync) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'bookings' AND column_name = 'google_calendar_event_id') THEN + ALTER TABLE bookings ADD COLUMN google_calendar_event_id VARCHAR(255); + CREATE INDEX IF NOT EXISTS idx_bookings_google_event ON bookings(google_calendar_event_id); + COMMENT ON COLUMN bookings.google_calendar_event_id IS 'Google Calendar event ID for this booking'; + END IF; +END +$$; + +-- Create trigger to automatically sync booking creation/update to Google Calendar +CREATE OR REPLACE FUNCTION trigger_sync_booking_to_google_calendar() +RETURNS TRIGGER AS $$ +BEGIN + -- Only sync if GOOGLE_CALENDAR_SYNC is enabled (check via environment variable) + -- This is handled at application level, not in database trigger + -- The trigger here is just a placeholder for potential future implementation + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Note: The actual sync logic is implemented in /lib/google-calendar.ts +-- This migration provides the database schema needed for bidirectional sync + +-- Create function to get blocking calendar events for a staff member +CREATE OR REPLACE FUNCTION get_blocking_calendar_events( + p_staff_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ +) +RETURNS TABLE ( + id UUID, + google_event_id VARCHAR(255), + title VARCHAR(500), + start_time_utc TIMESTAMPTZ, + end_time_utc TIMESTAMPTZ +) AS $$ +BEGIN + RETURN QUERY + SELECT + gce.id, + gce.google_event_id, + gce.title, + gce.start_time_utc, + gce.end_time_utc + FROM google_calendar_events gce + WHERE gce.staff_id = p_staff_id + AND gce.is_blocking = true + AND gce.start_time_utc < p_end_time_utc + AND gce.end_time_utc > p_start_time_utc + AND gce.is_anchoros_booking = false -- Only external events block + ORDER BY gce.start_time_utc; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create function to check if time slot conflicts with blocking calendar events +CREATE OR REPLACE FUNCTION check_calendar_blocking( + 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_has_conflict BOOLEAN := false; +BEGIN + -- Check for blocking calendar events (excluding AnchorOS bookings) + SELECT EXISTS( + SELECT 1 + FROM google_calendar_events gce + WHERE gce.staff_id = p_staff_id + AND gce.is_blocking = true + AND gce.start_time_utc < p_end_time_utc + AND gce.end_time_utc > p_start_time_utc + AND (p_exclude_booking_id IS NULL OR gce.booking_id != p_exclude_booking_id) + ) INTO v_has_conflict; + + RETURN NOT v_has_conflict; -- Return true if NO conflicts (available) +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant necessary permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON google_calendar_events TO authenticated; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO authenticated; + +-- RLS Policies for google_calendar_events +ALTER TABLE google_calendar_events ENABLE ROW LEVEL SECURITY; + +-- Policy: Staff can see their own calendar events +CREATE POLICY "Staff can view own calendar events" +ON google_calendar_events +FOR SELECT +USING ( + auth.uid()::text = (SELECT user_id::text FROM staff WHERE id = staff_id) + OR + (SELECT role FROM staff WHERE id = staff_id) IN ('admin', 'manager') +); + +-- Policy: Admins and managers can insert calendar events +CREATE POLICY "Admins and managers can insert calendar events" +ON google_calendar_events +FOR INSERT +WITH CHECK ( + EXISTS ( + SELECT 1 FROM staff + WHERE id = staff_id + AND user_id = auth.uid() + AND role IN ('admin', 'manager') + ) +); + +-- Policy: Admins and managers can update calendar events +CREATE POLICY "Admins and managers can update calendar events" +ON google_calendar_events +FOR UPDATE +USING ( + EXISTS ( + SELECT 1 FROM staff + WHERE id = staff_id + AND user_id = auth.uid() + AND role IN ('admin', 'manager') + ) +); + +-- Policy: Admins and managers can delete calendar events +CREATE POLICY "Admins and managers can delete calendar events" +ON google_calendar_events +FOR DELETE +USING ( + EXISTS ( + SELECT 1 FROM staff + WHERE id = staff_id + AND user_id = auth.uid() + AND role IN ('admin', 'manager') + ) +); diff --git a/supabase/migrations/20260118020000_update_staff_availability_calendar.sql b/supabase/migrations/20260118020000_update_staff_availability_calendar.sql new file mode 100644 index 0000000..e7a8cc6 --- /dev/null +++ b/supabase/migrations/20260118020000_update_staff_availability_calendar.sql @@ -0,0 +1,95 @@ +-- ============================================ +-- FASE 2.1 - UPDATE AVAILABILITY WITH CALENDAR SYNC +-- Date: 20260118 +-- Description: Update check_staff_availability to include Google Calendar conflicts +-- ============================================ + +/** + * @description Updated check_staff_availability with Google Calendar integration + * @param {UUID} p_staff_id - ID del staff a verificar + * @param {TIMESTAMPTZ} p_start_time_utc - Hora de inicio en UTC + * @param {TIMESTAMPTZ} p_end_time_utc - Hora de fin en UTC + * @param {UUID} p_exclude_booking_id - (Opcional) ID de reserva a excluir + * @returns {BOOLEAN} - true si el staff está disponible, false en caso contrario + * @example SELECT check_staff_availability('uuid...', NOW(), NOW() + INTERVAL '1 hour', NULL); + */ + +-- Drop existing function +DROP FUNCTION IF EXISTS check_staff_availability(UUID, TIMESTAMPTZ, TIMESTAMPTZ, UUID) CASCADE; + +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_staff RECORD; + v_has_work_conflict BOOLEAN := false; + v_has_booking_conflict BOOLEAN := false; + v_has_calendar_conflict BOOLEAN := false; + v_has_block_conflict BOOLEAN := false; +BEGIN + -- 1. Check if staff exists and is active + SELECT * INTO v_staff FROM staff WHERE id = p_staff_id; + + IF NOT FOUND OR NOT v_staff.is_active OR NOT v_staff.is_available_for_booking THEN + RETURN false; + END IF; + + -- 2. Check work hours and days + v_has_work_conflict := NOT check_staff_work_hours(p_staff_id, p_start_time_utc, p_end_time_utc); + IF v_has_work_conflict THEN + RETURN false; + END IF; + + -- 3. Check existing bookings conflict + SELECT EXISTS ( + SELECT 1 FROM bookings b + WHERE b.staff_id = p_staff_id + AND b.status != 'cancelled' + AND b.start_time_utc < p_end_time_utc + AND b.end_time_utc > p_start_time_utc + AND (p_exclude_booking_id IS NULL OR b.id != p_exclude_booking_id) + ) INTO v_has_booking_conflict; + + IF v_has_booking_conflict THEN + RETURN false; + END IF; + + -- 4. Check manual blocks conflict + SELECT EXISTS ( + SELECT 1 FROM staff_availability sa + WHERE sa.staff_id = p_staff_id + AND sa.date = p_start_time_utc::DATE + AND sa.is_available = false + AND (p_start_time_utc::TIME >= sa.start_time AND p_start_time_utc::TIME < sa.end_time + OR p_end_time_utc::TIME > sa.start_time AND p_end_time_utc::TIME <= sa.end_time + OR p_start_time_utc::TIME <= sa.start_time AND p_end_time_utc::TIME >= sa.end_time) + ) INTO v_has_block_conflict; + + IF v_has_block_conflict THEN + RETURN false; + END IF; + + -- 5. NEW: Check Google Calendar blocking events conflict + v_has_calendar_conflict := NOT check_calendar_blocking(p_staff_id, p_start_time_utc, p_end_time_utc, p_exclude_booking_id); + + IF v_has_calendar_conflict THEN + RETURN false; + END IF; + + -- All checks passed - staff is available + RETURN true; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Update get_detailed_availability to use the updated function (already calls it) +-- No change needed as it cascades + +-- Test function +COMMENT ON FUNCTION check_staff_availability IS 'Enhanced availability check including Google Calendar sync. Verifies work hours, bookings, manual blocks, and external calendar events.'; + +-- Grant execute permission +GRANT EXECUTE ON FUNCTION check_staff_availability TO authenticated, anon, service_role; diff --git a/supabase/migrations/20260118030000_dual_artist_support.sql b/supabase/migrations/20260118030000_dual_artist_support.sql new file mode 100644 index 0000000..b329899 --- /dev/null +++ b/supabase/migrations/20260118030000_dual_artist_support.sql @@ -0,0 +1,121 @@ +-- ============================================ +-- FASE 2.2 - DUAL ARTIST SERVICES SUPPORT +-- Date: 20260118 +-- Description: Add premium_amount to services and dual artist assignment functions +-- ============================================ + +-- Add premium_amount column to services +ALTER TABLE services ADD COLUMN IF NOT EXISTS premium_amount DECIMAL(10,2) DEFAULT 0; + +COMMENT ON COLUMN services.premium_amount IS 'Additional fee for premium/express services (auto-applied if premium_fee_enabled)'; + +-- Update seed data for express services (example) +UPDATE services +SET premium_amount = 500 +WHERE name LIKE '%Express%' OR requires_dual_artist = true; + +-- Create function to assign dual artists +CREATE OR REPLACE FUNCTION assign_dual_artists( + p_location_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ, + p_service_id UUID +) +RETURNS JSONB AS $$ +DECLARE + v_primary_artist UUID; + v_secondary_artist UUID; + v_room_resource UUID; + v_service RECORD; + v_artists JSONB; +BEGIN + -- Get service details + SELECT * INTO v_service FROM services WHERE id = p_service_id; + + IF NOT FOUND OR NOT v_service.requires_dual_artist THEN + RETURN jsonb_build_object( + 'primary_artist', NULL, + 'secondary_artist', NULL, + 'room_resource', NULL, + 'error', 'Service does not require dual artists' + ); + END IF; + + -- 1. Find available room resource + SELECT id INTO v_room_resource + FROM resources r + WHERE r.location_id = p_location_id + AND r.type = 'room' -- Assuming room type enum exists + AND check_resource_availability(r.id, p_start_time_utc, p_end_time_utc) + ORDER BY r.name -- or priority + LIMIT 1; + + IF v_room_resource IS NULL THEN + RETURN jsonb_build_object( + 'primary_artist', NULL, + 'secondary_artist', NULL, + 'room_resource', NULL, + 'error', 'No available room resource' + ); + END IF; + + -- 2. Find 2 available artists/staff (priority: artist > staff) + SELECT jsonb_agg(jsonb_build_object('id', s.id, 'display_name', s.display_name, 'role', s.role)) INTO v_artists + 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') + AND check_staff_availability(s.id, p_start_time_utc, p_end_time_utc) + ORDER BY + CASE s.role + WHEN 'artist' THEN 1 + WHEN 'staff' THEN 2 + END, + s.display_name + LIMIT 2; + + IF jsonb_array_length(v_artists) < 2 THEN + RETURN jsonb_build_object( + 'primary_artist', NULL, + 'secondary_artist', NULL, + 'room_resource', v_room_resource, + 'error', 'Insufficient available artists (need 2)' + ); + END IF; + + SELECT (v_artists->0)->>'id' INTO v_primary_artist; + SELECT (v_artists->1)->>'id' INTO v_secondary_artist; + + RETURN jsonb_build_object( + 'primary_artist', v_primary_artist, + 'secondary_artist', v_secondary_artist, + 'room_resource', v_room_resource, + 'success', true + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Create function to calculate service total with premium +CREATE OR REPLACE FUNCTION calculate_service_total(p_service_id UUID) +RETURNS DECIMAL(10,2) AS $$ +DECLARE + v_total DECIMAL(10,2); +BEGIN + SELECT + COALESCE(base_price, 0) + + CASE WHEN premium_fee_enabled THEN COALESCE(premium_amount, 0) ELSE 0 END + INTO v_total + FROM services + WHERE id = p_service_id; + + RETURN COALESCE(v_total, 0); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Grant permissions +GRANT EXECUTE ON FUNCTION assign_dual_artists TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION calculate_service_total TO authenticated, service_role; + +COMMENT ON FUNCTION assign_dual_artists IS 'Automatically assigns primary/secondary artists and room for dual-artist services'; +COMMENT ON FUNCTION calculate_service_total IS 'Calculates total price including premium fee if enabled'; diff --git a/supabase/migrations/20260118040000_enhanced_availability_priority.sql b/supabase/migrations/20260118040000_enhanced_availability_priority.sql new file mode 100644 index 0000000..1220c3a --- /dev/null +++ b/supabase/migrations/20260118040000_enhanced_availability_priority.sql @@ -0,0 +1,76 @@ +-- ============================================ +-- FASE 2.3 - ENHANCED AVAILABILITY WITH PRIORITY +-- Date: 20260118 +-- Description: Priority resource assignment + dual count + collision detection +-- ============================================ + +-- Enhance get_available_resources_with_priority with code priority +DROP FUNCTION IF EXISTS get_available_resources_with_priority(UUID, TIMESTAMPTZ, TIMESTAMPTZ) CASCADE; + +CREATE OR REPLACE FUNCTION get_available_resources_with_priority( + p_location_id UUID, + p_start_time_utc TIMESTAMPTZ, + p_end_time_utc TIMESTAMPTZ +) +RETURNS TABLE ( + resource_id UUID, + resource_name VARCHAR, + resource_type resource_type, + priority_order INTEGER +) AS $$ +BEGIN + RETURN QUERY + SELECT + r.id, + r.name, + r.type, + CASE + WHEN r.name LIKE 'mkup%' THEN 1 + WHEN r.name LIKE 'lshs%' THEN 2 + WHEN r.name LIKE 'pedi%' THEN 3 + WHEN r.name LIKE 'mani%' THEN 4 + ELSE 5 + END as priority_order + FROM resources r + WHERE r.location_id = p_location_id + AND r.is_active = true + AND check_resource_availability(r.id, p_start_time_utc, p_end_time_utc) + ORDER BY priority_order, r.name; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- New dual availability function +CREATE OR REPLACE FUNCTION get_dual_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_dual_slots JSONB := '[]'::JSONB; + -- ... (similar to get_detailed_availability but count pairs) +BEGIN + -- Reuse get_detailed_availability logic but filter COUNT >=2 + -- For simplicity, approximate with staff count >=2 + SELECT jsonb_agg(row_to_json(t)) + INTO v_dual_slots + FROM ( + SELECT + v_slot_start::TEXT as start_time, + (v_slot_start + (p_time_slot_duration_minutes || ' minutes')::INTERVAL)::TEXT as end_time, + available_staff_count >= 2 as available, + available_staff_count + FROM get_detailed_availability(p_location_id, p_service_id, p_date, p_time_slot_duration_minutes) slots + WHERE (slots->>'available_staff_count')::INT >= 2 + ) t; + + RETURN v_dual_slots; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +COMMENT ON FUNCTION get_available_resources_with_priority IS 'Available resources ordered by priority: mkup > lshs > pedi > mani'; +COMMENT ON FUNCTION get_dual_availability IS 'Availability slots where >=2 staff available (for dual services)'; + +GRANT EXECUTE ON FUNCTION get_available_resources_with_priority TO authenticated, service_role; +GRANT EXECUTE ON FUNCTION get_dual_availability TO authenticated, service_role;