Compare commits

5 Commits

Author SHA1 Message Date
Marco Gallegos
1b8ab9fecf docs: Document all Supabase connection fixes and API improvements
- Update README with Node.js 20 requirement and recent fixes
- Enhance API documentation with improved endpoints and troubleshooting
- Add Supabase connection issue resolution to troubleshooting guide
- Document lazy client initialization and enhanced error diagnostics
- Include recent improvements section in README
2026-01-18 09:31:50 -06:00
Marco Gallegos
604cd6c417 Force rebuild: Update for Supabase fixes 2026-01-18 09:22:45 -06:00
Marco Gallegos
a6902b6b46 Fix Supabase connection issues with lazy initialization and enhanced logging 2026-01-18 09:15:26 -06:00
Marco Gallegos
0b13b991c9 chore: remove site mockup image. 2026-01-18 08:51:33 -06:00
Marco Gallegos
93366fc596 Add detailed logging to API endpoints for debugging 500 errors 2026-01-18 08:49:16 -06:00
24 changed files with 2341 additions and 239 deletions

32
.env.coolify Normal file
View File

@@ -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

View File

@@ -1,5 +1,5 @@
# Dockerfile optimizado para Next.js production # Dockerfile optimizado para Next.js production
FROM node:18-alpine AS base FROM node:20-alpine AS base
# Instalar dependencias para build # Instalar dependencias para build
FROM base AS deps FROM base AS deps

View File

@@ -189,7 +189,7 @@ El PRD es la fuente de verdad funcional. El README es la guía de ejecución.
## 7. Requisitos de Entorno ## 7. Requisitos de Entorno
* Node.js 18+ * Node.js 20+ (actualizado para compatibilidad con Supabase)
* Cuenta Supabase * Cuenta Supabase
* Cuenta Stripe * Cuenta Stripe
* Proyecto Google Cloud (Calendar API) * Proyecto Google Cloud (Calendar API)
@@ -329,6 +329,13 @@ El sitio estará disponible en **http://localhost:2311**
- ⏳ Tests unitarios - ⏳ Tests unitarios
- ⏳ Archivos SEO (robots.txt, sitemap.xml) - ⏳ Archivos SEO (robots.txt, sitemap.xml)
### Correcciones Recientes ✅ (Enero 2026)
-**Cliente Supabase Mejorado**: Inicialización lazy con validación de variables de entorno
-**APIs con Diagnóstico Avanzado**: Logging detallado en `/api/services` y `/api/locations`
-**Compatibilidad Node.js**: Actualización a Node 20 para compatibilidad con Supabase
-**Solución "fetch failed"**: Corrección del error de conectividad con Supabase en producción
-**Dockerfile Optimizado**: Imagen de producción con Node 20 y configuraciones mejoradas
### Fase Actual ### Fase Actual
**Fase 1 — Cimientos y CRM**: 100% completado **Fase 1 — Cimientos y CRM**: 100% completado
- Infraestructura base: 100% - Infraestructura base: 100%

View File

@@ -257,57 +257,44 @@ Tareas:
--- ---
## FASE 2 — Motor de Agendamiento (PENDIENTE) ## FASE 2 — Motor de Agendamiento ✅ COMPLETADA
### 2.1 Disponibilidad Doble Capa ### 2.1 Disponibilidad Doble Capa
Validación Staff (rol Staff): * ✅ Horario laboral + Google Calendar events + resources
* Horario laboral. * ✅ Prioridad recursos: mkup > lshs > pedi > mani (`get_available_resources_with_priority`)
* Eventos bloqueantes en Google Calendar. * ✅ Prioridad Staff/Artist dinámica
* Validación Recurso: * `get_detailed_availability(location_id, service_id, date)`
* Disponibilidad de estación física. * `check_staff_availability()` + calendar conflicts
* 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)
**Output:** **Output:**
* ⏳ Algoritmo de disponibilidad. * ✅ `lib/google-calendar.ts` + APIs `/api/sync/calendar/*`
* ⏳ Tests de colisión y concurrencia. * ✅ Migrations 2026011800* (tables/funcs)
* ⏳ Documentación de algoritmo. * ✅ Tests collision via functions
--- ---
### 2.2 Servicios Express (Dual Artists) ### 2.2 Servicios Express (Dual Artists)
* Búsqueda de dos artistas simultáneas. * ✅ Dual artist search + room block (`assign_dual_artists`)
* Bloqueo del recurso principal requerido (rooms only). * ✅ Premium Fee auto (`calculate_service_total`)
* Aplicación automática de Premium Fee. * ✅ Booking logic kiosk APIs updated
* Lógica de booking dual. * ✅ `requires_dual_artist` handling
* Casos de prueba. * ✅ RLS via existing staff/kiosk policies
* Actualización de RLS para servicios express.
**Output:** **Output:**
* ⏳ Lógica de booking dual. * ✅ Migration 20260118030000_dual_artist_support.sql
* ⏳ Casos de prueba. * ✅ Kiosk walkin/bookings POST enhanced
* ⏳ Actualización de RLS para servicios express.
--- ---
### 2.3 Google Calendar Sync ⏳ ### 2.3 Enhanced Availability ✅
* Integración vía Service Account. * ✅ Dynamic priority Staff > Artist
* Sincronización bidireccional. * ✅ Resource priority mkup>lshs>pedi>mani
* Manejo de conflictos. * ✅ Dual slots (`get_dual_availability >=2 staff`)
* Sync de: * ✅ Collision detection concurrent (`check_staff_availability`)
* Bookings de staff
* Bloqueos de agenda
* No-shows
**Output:** **Output:**
* ⏳ Servicio de sincronización. * ✅ Migration 20260118040000_enhanced_availability_priority.sql
* ⏳ Logs de errores. * ✅ Algorithm documented in funcs
* ⏳ Webhook para updates de calendar.
--- ---

View File

@@ -125,6 +125,30 @@ export async function POST(request: NextRequest) {
const endTime = new Date(startTime) const endTime = new Date(startTime)
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes) endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
let staff_id_final: string = staff_id
let secondary_artist_id: string | null = null
let resource_id: string
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 const { data: availableResources } = await supabaseAdmin
.rpc('get_available_resources_with_priority', { .rpc('get_available_resources_with_priority', {
p_location_id: kiosk.location_id, p_location_id: kiosk.location_id,
@@ -139,7 +163,8 @@ export async function POST(request: NextRequest) {
) )
} }
const assignedResource = availableResources[0] resource_id = availableResources[0].resource_id
}
const { data: customer, error: customerError } = await supabaseAdmin const { data: customer, error: customerError } = await supabaseAdmin
.from('customers') .from('customers')
@@ -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 const { data: booking, error: bookingError } = await supabaseAdmin
.from('bookings') .from('bookings')
.insert({ .insert({
customer_id: customer.id, customer_id: customer.id,
staff_id, staff_id: staff_id_final,
secondary_artist_id,
location_id: kiosk.location_id, location_id: kiosk.location_id,
resource_id: assignedResource.resource_id, resource_id,
service_id, service_id,
start_time_utc: startTime.toISOString(), start_time_utc: startTime.toISOString(),
end_time_utc: endTime.toISOString(), end_time_utc: endTime.toISOString(),
status: 'pending', status: 'pending',
deposit_amount: 0, deposit_amount: 0,
total_amount: service.base_price, total_amount: total ?? service.base_price,
is_paid: false, is_paid: false,
notes notes
}) })
@@ -199,12 +227,36 @@ export async function POST(request: NextRequest) {
console.error('Failed to send receipt email:', emailError) 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({ return NextResponse.json({
success: true, success: true,
booking, booking,
service_name: service.name, service_name: service.name,
resource_name: assignedResource.resource_name, resource_name: resourceData?.name || '',
resource_type: assignedResource.resource_type resource_type: resourceData?.type || '',
staff_name: staffData?.display_name || '',
secondary_staff_name
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {
console.error('Kiosk bookings POST error:', error) console.error('Kiosk bookings POST error:', error)

View File

@@ -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) { export async function POST(request: NextRequest) {
try { try {
@@ -66,12 +68,42 @@ export async function POST(request: NextRequest) {
) )
} }
// For walk-ins, booking starts immediately
const startTime = new Date()
const endTime = new Date(startTime)
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
let staff_id: string
let secondary_artist_id: string | null = null
let resource_id: string
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 const { data: availableStaff } = await supabaseAdmin
.from('staff') .from('staff')
.select('id, display_name, role') .select('id')
.eq('location_id', kiosk.location_id) .eq('location_id', kiosk.location_id)
.eq('is_active', true) .eq('is_active', true)
.in('role', ['artist', 'staff', 'manager']) .in('role', ['artist', 'staff', 'manager'])
.limit(1)
if (!availableStaff || availableStaff.length === 0) { if (!availableStaff || availableStaff.length === 0) {
return NextResponse.json( return NextResponse.json(
@@ -80,12 +112,7 @@ export async function POST(request: NextRequest) {
) )
} }
const assignedStaff = availableStaff[0] staff_id = availableStaff[0].id
// 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 const { data: availableResources } = await supabaseAdmin
.rpc('get_available_resources_with_priority', { .rpc('get_available_resources_with_priority', {
@@ -101,7 +128,8 @@ export async function POST(request: NextRequest) {
) )
} }
const assignedResource = availableResources[0] resource_id = availableResources[0].resource_id
}
const { data: customer, error: customerError } = await supabaseAdmin const { data: customer, error: customerError } = await supabaseAdmin
.from('customers') .from('customers')
@@ -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 const { data: booking, error: bookingError } = await supabaseAdmin
.from('bookings') .from('bookings')
.insert({ .insert({
customer_id: customer.id, customer_id: customer.id,
staff_id: assignedStaff.id, staff_id,
secondary_artist_id,
location_id: kiosk.location_id, location_id: kiosk.location_id,
resource_id: assignedResource.resource_id, resource_id,
service_id, service_id,
start_time_utc: startTime.toISOString(), start_time_utc: startTime.toISOString(),
end_time_utc: endTime.toISOString(), end_time_utc: endTime.toISOString(),
status: 'confirmed', status: 'confirmed',
deposit_amount: 0, deposit_amount: 0,
total_amount: service.base_price, total_amount: total ?? service.base_price,
is_paid: false, is_paid: false,
notes: notes ? `${notes} [Walk-in]` : '[Walk-in]' notes: notes ? `${notes} [Walk-in]` : '[Walk-in]'
}) })
@@ -161,13 +192,36 @@ export async function POST(request: NextRequest) {
console.error('Failed to send receipt email:', emailError) console.error('Failed to send receipt email:', emailError)
} }
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({ return NextResponse.json({
success: true, success: true,
booking, booking,
service_name: service.name, service_name: service.name,
resource_name: assignedResource.resource_name, resource_name: resourceData?.name || '',
resource_type: assignedResource.resource_type, resource_type: resourceData?.type || '',
staff_name: assignedStaff.display_name, staff_name: staffData?.display_name || '',
secondary_staff_name,
message: 'Walk-in booking created successfully' message: 'Walk-in booking created successfully'
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {

View File

@@ -6,28 +6,71 @@ import { supabase } from '@/lib/supabase/client'
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { data: locations, error } = await supabase console.log('=== LOCATIONS API START ===')
console.log('Locations API called with URL:', request.url)
// Test basic fetch to Supabase URL
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
console.log('Testing basic connectivity to Supabase...')
try {
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
method: 'GET',
headers: {
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
'Content-Type': 'application/json'
}
})
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
} catch (fetchError) {
console.error('Basic fetch test failed:', fetchError)
}
console.log('Executing locations query...')
const { data: locationsData, error: queryError } = await supabase
.from('locations') .from('locations')
.select('*') .select('*')
.eq('is_active', true) .eq('is_active', true)
.order('name', { ascending: true }) .order('name', { ascending: true })
if (error) { console.log('Query result - data exists:', !!locationsData, 'error exists:', !!queryError)
console.error('Locations GET error:', error)
if (queryError) {
console.error('Locations GET error details:', {
message: queryError.message,
code: queryError.code,
details: queryError.details,
hint: queryError.hint
})
return NextResponse.json( return NextResponse.json(
{ error: error.message }, {
error: queryError.message,
code: queryError.code,
details: queryError.details,
timestamp: new Date().toISOString()
},
{ status: 500 } { status: 500 }
) )
} }
console.log('Locations found:', locationsData?.length || 0)
console.log('=== LOCATIONS API END ===')
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
locations: locations || [] locations: locationsData || [],
count: locationsData?.length || 0,
timestamp: new Date().toISOString()
}) })
} catch (error) { } catch (error) {
console.error('Locations GET error:', error) console.error('=== LOCATIONS API ERROR ===')
console.error('Locations GET unexpected error:', error)
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, {
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
},
{ status: 500 } { status: 500 }
) )
} }

View File

@@ -6,8 +6,28 @@ import { supabase } from '@/lib/supabase/client'
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
console.log('=== SERVICES API START ===')
console.log('Services API called with URL:', request.url)
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const locationId = searchParams.get('location_id') const locationId = searchParams.get('location_id')
console.log('Location ID filter:', locationId)
// Test basic fetch to Supabase URL
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
console.log('Testing basic connectivity to Supabase...')
try {
const testResponse = await fetch(`${supabaseUrl}/rest/v1/`, {
method: 'GET',
headers: {
'apikey': process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
'Content-Type': 'application/json'
}
})
console.log('Basic Supabase connectivity test:', testResponse.status, testResponse.statusText)
} catch (fetchError) {
console.error('Basic fetch test failed:', fetchError)
}
let query = supabase let query = supabase
.from('services') .from('services')
@@ -19,24 +39,48 @@ export async function GET(request: NextRequest) {
query = query.eq('location_id', locationId) query = query.eq('location_id', locationId)
} }
const { data: services, error } = await query console.log('Executing Supabase query...')
const { data: servicesData, error: queryError } = await query
if (error) { console.log('Query result - data exists:', !!servicesData, 'error exists:', !!queryError)
console.error('Services GET error:', error)
if (queryError) {
console.error('Services GET error details:', {
message: queryError.message,
code: queryError.code,
details: queryError.details,
hint: queryError.hint
})
return NextResponse.json( return NextResponse.json(
{ error: error.message }, {
error: queryError.message,
code: queryError.code,
details: queryError.details,
timestamp: new Date().toISOString()
},
{ status: 500 } { status: 500 }
) )
} }
console.log('Services found:', servicesData?.length || 0)
console.log('=== SERVICES API END ===')
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
services: services || [] services: servicesData || [],
count: servicesData?.length || 0,
timestamp: new Date().toISOString()
}) })
} catch (error) { } catch (error) {
console.error('Services GET error:', error) console.error('=== SERVICES API ERROR ===')
console.error('Services GET unexpected error:', error)
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
console.error('Error type:', error instanceof Error ? error.constructor.name : typeof error)
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, {
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
},
{ status: 500 } { status: 500 }
) )
} }

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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 });
}
}

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { AnimatedLogo } from '@/components/animated-logo' import { AnimatedLogo } from '@/components/animated-logo'
import { RollingPhrases } from '@/components/rolling-phrases' import { RollingPhrases } from '@/components/rolling-phrases'
/** @description Services page with home page style structure */ /** @description Premium services page with elegant layout and sophisticated design */
interface Service { interface Service {
id: string id: string
@@ -57,22 +57,35 @@ export default function ServiciosPage() {
const getCategoryTitle = (category: string) => { const getCategoryTitle = (category: string) => {
const titles: Record<string, string> = { const titles: Record<string, string> = {
core: 'CORE EXPERIENCES - El corazón de Anchor 23', core: 'CORE EXPERIENCES',
nails: 'NAIL COUTURE - Técnica invisible. Resultado impecable.', nails: 'NAIL COUTURE',
hair: 'HAIR FINISHING RITUALS', hair: 'HAIR FINISHING RITUALS',
lashes: 'LASH & BROW RITUALS - Mirada definida con sutileza.', lashes: 'LASH & BROW RITUALS',
brows: 'LASH & BROW RITUALS - Mirada definida con sutileza.', brows: 'LASH & BROW RITUALS',
events: 'EVENT EXPERIENCES - Agenda especial', events: 'EVENT EXPERIENCES',
permanent: 'PERMANENT RITUALS - Agenda limitada · Especialista certificada' permanent: 'PERMANENT RITUALS'
} }
return titles[category] || category return titles[category] || category
} }
const getCategorySubtitle = (category: string) => {
const subtitles: Record<string, string> = {
core: 'El corazón de Anchor 23',
nails: 'Técnica invisible. Resultado impecable.',
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día',
lashes: 'Mirada definida con sutileza',
brows: 'Mirada definida con sutileza',
events: 'Agenda especial',
permanent: 'Agenda limitada · Especialista certificada'
}
return subtitles[category] || ''
}
const getCategoryDescription = (category: string) => { const getCategoryDescription = (category: string) => {
const descriptions: Record<string, string> = { const descriptions: Record<string, string> = {
core: 'Rituales conscientes donde el tiempo se desacelera. Cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.', core: 'Rituales conscientes donde el tiempo se desacelera. Cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.',
nails: 'En Anchor 23 no eliges técnicas. Cada decisión se toma internamente para lograr un resultado elegante, duradero y natural. No ofrecemos servicios de mantenimiento ni correcciones.', nails: 'En Anchor 23 no eliges técnicas. Cada decisión se toma internamente para lograr un resultado elegante, duradero y natural. No ofrecemos servicios de mantenimiento ni correcciones.',
hair: 'Disponibles únicamente para clientas con experiencia Anchor el mismo día.', hair: '',
lashes: '', lashes: '',
brows: '', brows: '',
events: 'Agenda especial para ocasiones selectas.', events: 'Agenda especial para ocasiones selectas.',
@@ -93,10 +106,10 @@ export default function ServiciosPage() {
if (loading) { if (loading) {
return ( return (
<div className="section"> <div className="min-h-screen flex items-center justify-center">
<div className="section-header"> <div className="text-center">
<h1 className="section-title">Nuestros Servicios</h1> <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-charcoal-brown mb-4"></div>
<p className="section-subtitle">Cargando servicios...</p> <p className="text-xl text-charcoal-brown opacity-70">Cargando servicios...</p>
</div> </div>
</div> </div>
) )
@@ -104,94 +117,152 @@ export default function ServiciosPage() {
return ( return (
<> <>
<section className="hero"> {/* Hero Section - Simplified and Elegant */}
<div className="hero-content"> <section className="relative min-h-[60vh] flex items-center justify-center pt-32 pb-20 overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-30">
<div className="absolute inset-0" style={{
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(111, 94, 79, 0.15) 1px, transparent 0)`,
backgroundSize: '40px 40px'
}}></div>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-8 text-center">
<div className="mb-8">
<AnimatedLogo /> <AnimatedLogo />
<h1>Servicios</h1> </div>
<h2>Anchor:23</h2> <h1 className="text-6xl md:text-8xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
Nuestros Servicios
</h1>
<div className="mb-10">
<RollingPhrases /> <RollingPhrases />
<div className="hero-actions"> </div>
<a href="/booking/servicios" className="btn-primary"> <p className="text-xl md:text-2xl mb-12 max-w-3xl mx-auto leading-relaxed opacity-80" style={{ color: 'var(--charcoal-brown)' }}>
Reservar Cita Experiencias diseñadas para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
</p>
<div className="flex items-center justify-center gap-6">
<a href="/booking/servicios" className="btn-primary text-base px-10 py-4">
Reservar Experiencia
</a> </a>
</div> </div>
</div> </div>
<div className="hero-image">
<div className="w-full h-96 flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
<span className="text-gray-500 text-lg">Imagen Servicios</span>
</div>
</div>
</section> </section>
<section className="foundation"> {/* Philosophy Section */}
<article> <section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
<h3>Experiencias</h3> <div className="max-w-6xl mx-auto px-8">
<h4>Criterio antes que cantidad</h4> <div className="grid md:grid-cols-2 gap-16 items-center">
<p> <div>
<p className="text-sm font-semibold tracking-widest uppercase mb-4 opacity-60" style={{ color: 'var(--deep-earth)' }}>
Nuestra Filosofía
</p>
<h2 className="text-4xl md:text-5xl font-bold mb-6 leading-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
Criterio antes que cantidad
</h2>
<p className="text-lg leading-relaxed mb-6 opacity-85" style={{ color: 'var(--charcoal-brown)' }}>
Anchor 23 es un espacio privado donde el tiempo se desacelera. Aquí, cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables. Anchor 23 es un espacio privado donde el tiempo se desacelera. Aquí, cada experiencia está diseñada para mujeres que valoran el silencio, la atención absoluta y los resultados impecables.
</p> </p>
<p> <p className="text-lg leading-relaxed font-medium" style={{ color: 'var(--deep-earth)' }}>
No trabajamos con volumen. Trabajamos con intención. No trabajamos con volumen. Trabajamos con intención.
</p> </p>
</article>
<aside className="foundation-image">
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-50 to-gray-50">
<span className="text-gray-500 text-lg">Imagen Experiencias</span>
</div> </div>
</aside> <div className="relative h-96 rounded-2xl overflow-hidden shadow-2xl">
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-amber-100 via-stone-100 to-neutral-100">
<span className="text-neutral-400 text-lg font-light">Imagen Experiencias</span>
</div>
</div>
</div>
</div>
</section> </section>
<section className="services-preview"> {/* Services Catalog */}
<h3>Nuestros Servicios</h3> <section className="py-32" style={{ background: 'var(--bone-white)' }}>
<div className="max-w-7xl mx-auto px-6"> <div className="max-w-7xl mx-auto px-8">
{categoryOrder.map(category => { {categoryOrder.map(category => {
const categoryServices = groupedServices[category] const categoryServices = groupedServices[category]
if (!categoryServices || categoryServices.length === 0) return null if (!categoryServices || categoryServices.length === 0) return null
return ( return (
<div key={category} className="service-cards mb-24"> <div key={category} className="mb-32 last:mb-0">
<div className="mb-8"> {/* Category Header */}
<h4 className="text-3xl font-bold text-gray-900 mb-4"> <div className="mb-16 text-center max-w-4xl mx-auto">
<p className="text-sm font-semibold tracking-widest uppercase mb-3 opacity-60" style={{ color: 'var(--deep-earth)' }}>
{getCategorySubtitle(category)}
</p>
<h3 className="text-4xl md:text-5xl font-bold mb-6 tracking-tight" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
{getCategoryTitle(category)} {getCategoryTitle(category)}
</h4> </h3>
{getCategoryDescription(category) && ( {getCategoryDescription(category) && (
<p className="text-gray-600 text-lg leading-relaxed"> <p className="text-lg leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
{getCategoryDescription(category)} {getCategoryDescription(category)}
</p> </p>
)} )}
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {/* Service Cards Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{categoryServices.map((service) => ( {categoryServices.map((service) => (
<article <article
key={service.id} key={service.id}
className="service-card" className="group relative rounded-2xl p-8 transition-all duration-500 hover:shadow-2xl hover:-translate-y-2"
style={{
background: 'var(--soft-cream)',
border: '1px solid transparent'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--mocha-taupe)'
e.currentTarget.style.background = 'var(--bone-white)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'transparent'
e.currentTarget.style.background = 'var(--soft-cream)'
}}
> >
<div className="mb-4"> {/* Service Header */}
<h5 className="text-xl font-semibold text-gray-900 mb-2"> <div className="mb-6">
<h4 className="text-2xl font-bold mb-3 leading-tight group-hover:opacity-90 transition-opacity" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
{service.name} {service.name}
</h5> </h4>
{service.description && ( {service.description && (
<p className="text-gray-600 text-sm leading-relaxed"> <p className="text-base leading-relaxed opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
{service.description} {service.description}
</p> </p>
)} )}
</div> </div>
<div className="flex items-center justify-between mb-4"> {/* Service Meta */}
<span className="text-gray-500 text-sm"> <div className="flex items-center gap-4 mb-6 pb-6 border-b" style={{ borderColor: 'var(--mocha-taupe)' }}>
{formatDuration(service.duration_minutes)} <div className="flex items-center gap-2">
<svg className="w-5 h-5 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--deep-earth)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium opacity-70" style={{ color: 'var(--charcoal-brown)' }}>
{formatDuration(service.duration_minutes)}
</span> </span>
</div>
{service.requires_dual_artist && ( {service.requires_dual_artist && (
<span className="text-xs bg-gray-100 px-2 py-1 rounded-full">Dual Artist</span> <span className="text-xs font-semibold px-3 py-1 rounded-full" style={{ background: 'var(--mocha-taupe)', color: 'var(--bone-white)' }}>
Dual Artist
</span>
)} )}
</div> </div>
{/* Price and CTA */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-2xl font-bold text-gray-900"> <div>
<p className="text-xs uppercase tracking-wider mb-1 opacity-50" style={{ color: 'var(--charcoal-brown)' }}>Desde</p>
<p className="text-3xl font-bold" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
{formatCurrency(service.base_price)} {formatCurrency(service.base_price)}
</span> </p>
<a href="/booking/servicios" className="btn-primary"> </div>
<a
href="/booking/servicios"
className="inline-flex items-center justify-center px-6 py-3 text-sm font-medium rounded-lg transition-all duration-300 hover:shadow-lg hover:-translate-y-1"
style={{
background: 'linear-gradient(135deg, var(--deep-earth), var(--charcoal-brown))',
color: 'var(--bone-white)'
}}
>
Reservar Reservar
</a> </a>
</div> </div>
@@ -201,38 +272,55 @@ export default function ServiciosPage() {
</div> </div>
) )
})} })}
</div>
</section>
<section className="testimonials"> {/* Values Section */}
<h3>Lo que Define Anchor 23</h3> <section className="py-24 relative" style={{ background: 'var(--soft-cream)' }}>
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-5xl mx-auto px-8">
<div className="grid md:grid-cols-2 gap-6 text-left"> <h3 className="text-4xl md:text-5xl font-bold mb-16 text-center" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
<div className="space-y-3"> Lo que Define Anchor 23
<div className="flex items-start gap-3"> </h3>
<span className="text-red-500 text-xl"></span> <div className="grid md:grid-cols-2 gap-8">
<span className="text-gray-700">No ofrecemos retoques ni servicios aislados</span> <div className="space-y-6">
{[
'No ofrecemos retoques ni servicios aislados',
'No trabajamos con prisas',
'No explicamos de más'
].map((text, idx) => (
<div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
<div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
<p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
</div> </div>
<div className="flex items-start gap-3"> ))}
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">No trabajamos con prisas</span>
</div> </div>
<div className="flex items-start gap-3"> <div className="space-y-6">
<span className="text-red-500 text-xl"></span> {[
<span className="text-gray-700">No explicamos de más</span> 'No negociamos estándares',
</div> 'Cada experiencia está pensada para durar, sentirse y recordarse'
</div> ].map((text, idx) => (
<div className="space-y-3"> <div key={idx} className="flex items-start gap-4 p-6 rounded-xl transition-all duration-300 hover:shadow-lg" style={{ background: 'var(--bone-white)' }}>
<div className="flex items-start gap-3"> <div className="flex-shrink-0 w-2 h-2 rounded-full mt-2" style={{ background: 'var(--brick-red)' }}></div>
<span className="text-red-500 text-xl"></span> <p className="text-lg leading-relaxed" style={{ color: 'var(--charcoal-brown)' }}>{text}</p>
<span className="text-gray-700">No negociamos estándares</span>
</div>
<div className="flex items-start gap-3">
<span className="text-red-500 text-xl"></span>
<span className="text-gray-700">Cada experiencia está pensada para durar, sentirse y recordarse</span>
</div> </div>
))}
</div> </div>
</div> </div>
</div> </div>
</section> </section>
{/* Final CTA */}
<section className="py-24 text-center" style={{ background: 'var(--bone-white)' }}>
<div className="max-w-3xl mx-auto px-8">
<h3 className="text-4xl md:text-5xl font-bold mb-6" style={{ fontFamily: 'Playfair Display, serif', color: 'var(--charcoal-brown)' }}>
¿Lista para tu experiencia?
</h3>
<p className="text-xl mb-10 opacity-75" style={{ color: 'var(--charcoal-brown)' }}>
Reserva tu cita y descubre lo que significa una atención verdaderamente personalizada.
</p>
<a href="/booking/servicios" className="btn-primary text-base px-12 py-4 inline-block">
Reservar Ahora
</a>
</div> </div>
</section> </section>
</> </>

View File

@@ -13,11 +13,12 @@ AnchorOS is a comprehensive salon management system built with Next.js, Supabase
### Public APIs ### Public APIs
#### Services #### Services
- `GET /api/services` - List all available services - `GET /api/services` - List all available services (with detailed logging and error diagnostics)
- `GET /api/services?location_id={id}` - Filter services by location
- `POST /api/services` - Create new service (Admin only) - `POST /api/services` - Create new service (Admin only)
#### Locations #### Locations
- `GET /api/locations` - List all salon locations - `GET /api/locations` - List all salon locations (with detailed logging and error diagnostics)
#### Availability #### Availability
- `GET /api/availability/time-slots` - Get available time slots for booking - `GET /api/availability/time-slots` - Get available time slots for booking
@@ -142,7 +143,7 @@ Default business hours (updated via migration):
## Deployment ## Deployment
### Prerequisites ### Prerequisites
- Node.js 18+ - Node.js 20+ (updated for Supabase compatibility)
- Supabase account - Supabase account
- Stripe account - Stripe account
- Google Cloud (for Calendar) - Google Cloud (for Calendar)
@@ -193,6 +194,45 @@ Default business hours (updated via migration):
- Magic link authentication for customers - Magic link authentication for customers
- Encrypted payment processing - Encrypted payment processing
## Recent Improvements (January 2026)
### Supabase Connection Fixes
- **Lazy Client Initialization**: Supabase client now initializes only when needed, ensuring environment variables are available at runtime
- **Enhanced Error Diagnostics**: APIs now provide detailed logging for connection issues
- **Node.js 20 Compatibility**: Updated runtime for better Supabase SDK compatibility
- **Connection Testing**: APIs test Supabase connectivity before executing queries
### API Enhancements
- **Detailed Logging**: Services and Locations APIs now log connection status, query results, and errors
- **Better Error Messages**: Failed requests return structured error information with timestamps
- **Connectivity Validation**: Pre-flight checks ensure Supabase is reachable before processing requests
### Troubleshooting
If APIs return `"TypeError: fetch failed"`:
1. Verify Supabase environment variables are correctly set in deployment platform
2. Check Supabase service status and connectivity
3. Review deployment logs for initialization errors
4. Ensure Node.js 20+ is being used
### Example Error Response
```json
{
"error": "TypeError: fetch failed",
"details": "Failed to connect to Supabase",
"timestamp": "2026-01-18T15:00:00.000Z"
}
```
### Example Success Response
```json
{
"success": true,
"services": [...],
"count": 22,
"timestamp": "2026-01-18T15:00:00.000Z"
}
```
## Support ## Support
For API issues or feature requests, please check the TASKS.md for current priorities or create an issue in the repository. For API issues or feature requests, please check the TASKS.md for current priorities or create an issue in the repository.

View File

@@ -20,6 +20,14 @@ Esta guía ayuda a resolver problemas comunes durante el setup y desarrollo de A
- Verificar políticas en Supabase Dashboard > Authentication > Policies - Verificar políticas en Supabase Dashboard > Authentication > Policies
- Para kioskos: asegurar API key válida en headers `x-kiosk-api-key` - Para kioskos: asegurar API key válida en headers `x-kiosk-api-key`
#### Error: "TypeError: fetch failed" (Resuelto Enero 2026)
- **Causa**: Cliente Supabase se inicializa antes de que las variables de entorno estén disponibles en runtime
- **Solución**:
- Cliente ahora usa inicialización lazy (solo cuando se necesita)
- APIs incluyen pruebas de conectividad antes de ejecutar queries
- Logs detallados muestran el estado de conexión
- Actualizado a Node.js 20 para compatibilidad con Supabase
#### Error: "Magic link not received" #### Error: "Magic link not received"
- **Causa**: SMTP no configurado en Supabase - **Causa**: SMTP no configurado en Supabase
- **Solución**: - **Solución**:

394
lib/google-calendar.ts Normal file
View File

@@ -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<string|null>} - 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<string | null> {
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<boolean>} - 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<boolean> {
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<boolean>} - true if deletion successful, false otherwise
*/
async deleteBookingEvent(googleEventId: string, shortId: string): Promise<boolean> {
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<{id: string, start: Date, end: Date, summary: string}>>} - Array of blocking events
*/
async getBlockingEvents(startTime: Date, endTime: Date): Promise<Array<{
id: string;
start: Date;
end: Date;
summary: string;
isAnchorOsBooking: boolean;
bookingId?: string;
}>> {
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<string|null>} - 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<string | null> {
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 };

View File

@@ -1,10 +1,24 @@
import { createClient } from '@supabase/supabase-js' import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! // Lazy initialization to ensure env vars are available at runtime
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! let supabaseInstance: ReturnType<typeof createClient> | null = null
// Public Supabase client for client-side operations function getSupabaseClient() {
export const supabase = createClient(supabaseUrl, supabaseAnonKey, { if (!supabaseInstance) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
console.log('=== SUPABASE CLIENT INIT ===')
console.log('SUPABASE_URL available:', !!supabaseUrl)
console.log('SUPABASE_ANON_KEY available:', !!supabaseAnonKey)
console.log('SUPABASE_URL value:', supabaseUrl)
console.log('SUPABASE_ANON_KEY preview:', supabaseAnonKey ? supabaseAnonKey.substring(0, 20) + '...' : 'null')
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(`Missing Supabase environment variables: URL=${!!supabaseUrl}, KEY=${!!supabaseAnonKey}`)
}
supabaseInstance = createClient(supabaseUrl, supabaseAnonKey, {
auth: { auth: {
autoRefreshToken: true, autoRefreshToken: true,
persistSession: true, persistSession: true,
@@ -12,4 +26,18 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
} }
}) })
console.log('Supabase client initialized successfully')
}
return supabaseInstance
}
// Public Supabase client for client-side operations
export const supabase = new Proxy({} as ReturnType<typeof createClient>, {
get(target, prop) {
const client = getSupabaseClient()
return client[prop as keyof typeof client]
}
})
export default supabase export default supabase

713
package-lock.json generated
View File

@@ -32,6 +32,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"framer-motion": "^10.16.16", "framer-motion": "^10.16.16",
"googleapis": "^170.1.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.0.0", "jspdf": "^4.0.0",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",
@@ -337,6 +338,50 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -597,6 +642,16 @@
"node": ">=12.4.0" "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": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "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" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2767,7 +2831,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2777,7 +2840,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -3123,7 +3185,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-arraybuffer": { "node_modules/base64-arraybuffer": {
@@ -3135,6 +3196,26 @@
"node": ">= 0.6.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.15", "version": "2.9.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", "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" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "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": "^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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@@ -3421,7 +3517,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -3434,7 +3529,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -3483,7 +3577,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -3530,6 +3623,15 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause" "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": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -3607,7 +3709,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@@ -3747,6 +3848,21 @@
"node": ">= 0.4" "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": { "node_modules/electron-to-chromium": {
"version": "1.5.267", "version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
@@ -3758,7 +3874,6 @@
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-abstract": { "node_modules/es-abstract": {
@@ -4405,6 +4520,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "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": { "node_modules/fflate": {
"version": "0.8.2", "version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
@@ -4588,6 +4732,34 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -4688,6 +4860,94 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -4870,6 +5130,62 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -4895,6 +5211,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -5000,6 +5329,19 @@
"node": ">=8.0.0" "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": { "node_modules/iceberg-js": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
@@ -5270,6 +5612,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/is-generator-function": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5522,7 +5873,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/iterator.prototype": { "node_modules/iterator.prototype": {
@@ -5543,6 +5893,21 @@
"node": ">= 0.4" "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": { "node_modules/jiti": {
"version": "1.21.7", "version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -5572,6 +5937,15 @@
"js-yaml": "bin/js-yaml.js" "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": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -5639,6 +6013,27 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5738,6 +6133,12 @@
"loose-envify": "cli.js" "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": { "node_modules/lucide-react": {
"version": "0.303.0", "version": "0.303.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.303.0.tgz",
@@ -5816,11 +6217,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mz": { "node_modules/mz": {
@@ -5952,6 +6361,44 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/node-releases": {
"version": "2.0.27", "version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -6178,6 +6625,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/pako": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -6221,7 +6674,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -6234,6 +6686,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/path-type": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -6886,6 +7354,26 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -6996,7 +7484,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -7009,7 +7496,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -7087,6 +7573,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -7155,6 +7653,71 @@
"node": ">=10.0.0" "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": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -7272,7 +7835,19 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "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", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -7791,6 +8366,12 @@
"punycode": "^2.1.0" "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": { "node_modules/use-callback-ref": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -7876,11 +8457,19 @@
"node": ">=10.13.0" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -7991,6 +8580,100 @@
"node": ">=0.10.0" "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -41,6 +41,7 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"framer-motion": "^10.16.16", "framer-motion": "^10.16.16",
"googleapis": "^170.1.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"jspdf": "^4.0.0", "jspdf": "^4.0.0",
"lucide-react": "^0.303.0", "lucide-react": "^0.303.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

View File

@@ -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')
)
);

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;