Add detailed logging to API endpoints for debugging 500 errors

This commit is contained in:
Marco Gallegos
2026-01-18 08:49:16 -06:00
parent c0a9568e5c
commit 93366fc596
17 changed files with 2020 additions and 127 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

@@ -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,22 +125,47 @@ 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)
const { data: availableResources } = await supabaseAdmin let staff_id_final: string = staff_id
.rpc('get_available_resources_with_priority', { let secondary_artist_id: string | null = null
p_location_id: kiosk.location_id, let resource_id: string
p_start_time: startTime.toISOString(),
p_end_time: endTime.toISOString()
})
if (!availableResources || availableResources.length === 0) { if (service.requires_dual_artist) {
return NextResponse.json( const { data: assignment } = await supabaseAdmin
{ error: 'No resources available for the selected time' }, .rpc('assign_dual_artists', {
{ status: 400 } p_location_id: kiosk.location_id,
) p_start_time_utc: startTime.toISOString(),
p_end_time_utc: endTime.toISOString(),
p_service_id: service.id
})
if (!assignment || !assignment.success) {
return NextResponse.json(
{ error: assignment?.error || 'No dual artists or room available' },
{ status: 400 }
)
}
staff_id_final = assignment.primary_artist
secondary_artist_id = assignment.secondary_artist
resource_id = assignment.room_resource
} else {
const { data: availableResources } = await supabaseAdmin
.rpc('get_available_resources_with_priority', {
p_location_id: kiosk.location_id,
p_start_time: startTime.toISOString(),
p_end_time: endTime.toISOString()
})
if (!availableResources || availableResources.length === 0) {
return NextResponse.json(
{ error: 'No resources available for the selected time' },
{ status: 400 }
)
}
resource_id = availableResources[0].resource_id
} }
const assignedResource = availableResources[0]
const { data: customer, error: customerError } = await supabaseAdmin const { data: customer, error: customerError } = await supabaseAdmin
.from('customers') .from('customers')
.upsert({ .upsert({
@@ -161,19 +186,22 @@ export async function POST(request: NextRequest) {
) )
} }
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
const { data: booking, error: bookingError } = await supabaseAdmin 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,43 +68,69 @@ export async function POST(request: NextRequest) {
) )
} }
const { data: availableStaff } = await supabaseAdmin
.from('staff')
.select('id, display_name, role')
.eq('location_id', kiosk.location_id)
.eq('is_active', true)
.in('role', ['artist', 'staff', 'manager'])
if (!availableStaff || availableStaff.length === 0) {
return NextResponse.json(
{ error: 'No staff available' },
{ status: 400 }
)
}
const assignedStaff = availableStaff[0]
// For walk-ins, booking starts immediately // For walk-ins, booking starts immediately
const startTime = new Date() const startTime = new Date()
const endTime = new Date(startTime) const endTime = new Date(startTime)
endTime.setMinutes(endTime.getMinutes() + service.duration_minutes) endTime.setMinutes(endTime.getMinutes() + service.duration_minutes)
const { data: availableResources } = await supabaseAdmin let staff_id: string
.rpc('get_available_resources_with_priority', { let secondary_artist_id: string | null = null
p_location_id: kiosk.location_id, let resource_id: string
p_start_time: startTime.toISOString(),
p_end_time: endTime.toISOString()
})
if (!availableResources || availableResources.length === 0) { if (service.requires_dual_artist) {
return NextResponse.json( const { data: assignment } = await supabaseAdmin
{ error: 'No resources available for immediate booking' }, .rpc('assign_dual_artists', {
{ status: 400 } p_location_id: kiosk.location_id,
) p_start_time_utc: startTime.toISOString(),
p_end_time_utc: endTime.toISOString(),
p_service_id: service.id
})
if (!assignment || !assignment.success) {
return NextResponse.json(
{ error: assignment?.error || 'No dual artists or room available' },
{ status: 400 }
)
}
staff_id = assignment.primary_artist
secondary_artist_id = assignment.secondary_artist
resource_id = assignment.room_resource
} else {
const { data: availableStaff } = await supabaseAdmin
.from('staff')
.select('id')
.eq('location_id', kiosk.location_id)
.eq('is_active', true)
.in('role', ['artist', 'staff', 'manager'])
.limit(1)
if (!availableStaff || availableStaff.length === 0) {
return NextResponse.json(
{ error: 'No staff available' },
{ status: 400 }
)
}
staff_id = availableStaff[0].id
const { data: availableResources } = await supabaseAdmin
.rpc('get_available_resources_with_priority', {
p_location_id: kiosk.location_id,
p_start_time: startTime.toISOString(),
p_end_time: endTime.toISOString()
})
if (!availableResources || availableResources.length === 0) {
return NextResponse.json(
{ error: 'No resources available for immediate booking' },
{ status: 400 }
)
}
resource_id = availableResources[0].resource_id
} }
const assignedResource = availableResources[0]
const { data: customer, error: customerError } = await supabaseAdmin const { data: customer, error: customerError } = await supabaseAdmin
.from('customers') .from('customers')
.upsert({ .upsert({
@@ -123,19 +151,22 @@ export async function POST(request: NextRequest) {
) )
} }
const { data: total } = await supabaseAdmin.rpc('calculate_service_total', { p_service_id: service.id })
const { data: booking, error: bookingError } = await supabaseAdmin 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,15 +192,38 @@ export async function POST(request: NextRequest) {
console.error('Failed to send receipt email:', emailError) console.error('Failed to send receipt email:', emailError)
} }
return NextResponse.json({ const { data: staffData } = await supabaseAdmin
success: true, .from('staff')
booking, .select('display_name')
service_name: service.name, .eq('id', staff_id)
resource_name: assignedResource.resource_name, .single()
resource_type: assignedResource.resource_type,
staff_name: assignedStaff.display_name, const { data: resourceData } = await supabaseAdmin
message: 'Walk-in booking created successfully' .from('resources')
}, { status: 201 }) .select('name, type')
.eq('id', resource_id)
.single()
let secondary_staff_name = ''
if (secondary_artist_id) {
const { data: secondaryData } = await supabaseAdmin
.from('staff')
.select('display_name')
.eq('id', secondary_artist_id)
.single()
secondary_staff_name = secondaryData?.display_name || ''
}
return NextResponse.json({
success: true,
booking,
service_name: service.name,
resource_name: resourceData?.name || '',
resource_type: resourceData?.type || '',
staff_name: staffData?.display_name || '',
secondary_staff_name,
message: 'Walk-in booking created successfully'
}, { status: 201 })
} catch (error) { } catch (error) {
console.error('Kiosk walk-in error:', error) console.error('Kiosk walk-in error:', error)
return NextResponse.json( return NextResponse.json(

View File

@@ -6,28 +6,52 @@ 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 called with URL:', request.url)
// Check Supabase connection
console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL)
console.log('Supabase key exists:', !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
console.log('Executing locations query...')
const { data: locationsData, error: queryError } = await supabase
.from('locations') .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:', !!locationsData, 'error:', !!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
},
{ status: 500 } { status: 500 }
) )
} }
console.log('Locations found:', locationsData?.length || 0)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
locations: locations || [] locations: locationsData || [],
count: locationsData?.length || 0
}) })
} catch (error) { } catch (error) {
console.error('Locations GET error:', error) console.error('Locations GET unexpected error:', error)
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, {
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 } { status: 500 }
) )
} }

View File

@@ -6,8 +6,15 @@ import { supabase } from '@/lib/supabase/client'
*/ */
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
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)
// Check Supabase connection
console.log('Supabase URL:', process.env.NEXT_PUBLIC_SUPABASE_URL)
console.log('Supabase key exists:', !!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
let query = supabase let query = supabase
.from('services') .from('services')
@@ -19,24 +26,42 @@ 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 query...')
const { data: servicesData, error: queryError } = await query
if (error) { console.log('Query result - data:', !!servicesData, 'error:', !!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
},
{ status: 500 } { status: 500 }
) )
} }
console.log('Services found:', servicesData?.length || 0)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
services: services || [] services: servicesData || [],
count: servicesData?.length || 0
}) })
} catch (error) { } catch (error) {
console.error('Services GET error:', error) console.error('Services GET unexpected error:', error)
console.error('Error stack:', error instanceof Error ? error.stack : 'Unknown error')
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, {
error: 'Internal server error',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ 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 });
}
}

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

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",

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;