From d27354fd5a7e3b665a3c04265a73e3c37045a267 Mon Sep 17 00:00:00 2001 From: Marco Gallegos Date: Wed, 21 Jan 2026 13:02:06 -0600 Subject: [PATCH] feat: Add kiosk management, artist selection, and schedule management - Add KiosksManagement component with full CRUD for kiosks - Add ScheduleManagement for staff schedules with break reminders - Update booking flow to allow artist selection by customers - Add staff_services API for assigning services to artists - Update staff management UI with service assignment dialog - Add auto-break reminder when schedule >= 8 hours - Update availability API to filter artists by service - Add kiosk management to Aperture dashboard - Clean up ralphy artifacts and logs --- .env.js | 17 - .env.template | 40 + .gitignore | 19 + .ralphy/progress.txt | 2 - PRD.md | 14 +- README.md | 20 +- TASKS.md | 84 +- app/aperture/calendar/page.tsx | 17 +- app/aperture/page.tsx | 52 +- app/api/aperture/bookings/check-in/route.ts | 13 +- app/api/aperture/bookings/no-show/route.ts | 14 +- .../aperture/calendar/auto-assign/route.ts | 114 + app/api/aperture/clients/[id]/notes/route.ts | 15 +- app/api/aperture/clients/[id]/photos/route.ts | 30 +- app/api/aperture/clients/[id]/route.ts | 29 +- app/api/aperture/clients/route.ts | 26 +- app/api/aperture/dashboard/route.ts | 12 +- .../aperture/finance/daily-closing/route.ts | 12 +- app/api/aperture/finance/expenses/route.ts | 26 +- .../finance/staff-performance/route.ts | 12 +- app/api/aperture/kiosks/[id]/route.ts | 132 + app/api/aperture/kiosks/route.ts | 127 + app/api/aperture/locations/route.ts | 10 +- app/api/aperture/loyalty/route.ts | 13 +- app/api/aperture/payroll/route.ts | 15 +- app/api/aperture/pos/close-day/route.ts | 18 +- app/api/aperture/pos/route.ts | 17 +- app/api/aperture/reports/payments/route.ts | 10 +- app/api/aperture/reports/payroll/route.ts | 10 +- app/api/aperture/reports/sales/route.ts | 10 +- app/api/aperture/resources/[id]/route.ts | 36 +- app/api/aperture/staff/[id]/route.ts | 24 +- app/api/aperture/staff/[id]/services/route.ts | 247 ++ app/api/aperture/staff/role/route.ts | 10 +- app/api/aperture/staff/schedule/route.ts | 32 +- app/api/availability/blocks/route.ts | 35 +- .../availability/staff-unavailable/route.ts | 27 +- app/api/availability/staff/route.ts | 116 +- app/api/availability/time-slots/route.ts | 11 +- app/api/bookings/[id]/route.ts | 11 +- app/api/bookings/route.ts | 88 +- app/api/create-payment-intent/route.ts | 13 +- app/api/kiosk/bookings/route.ts | 19 +- app/api/public/availability/route.ts | 10 +- app/api/receipts/[bookingId]/route.ts | 14 +- app/api/webhooks/stripe/route.ts | 14 +- app/booking/cita/page.tsx | 9 +- app/booking/servicios/page.tsx | 148 +- app/kiosk/[locationId]/page.tsx | 14 +- components/auth-guard.tsx | 11 +- components/booking/date-picker.tsx | 15 + components/calendar-view.tsx | 352 ++- components/kiosk/BookingConfirmation.tsx | 20 +- components/kiosk/WalkInFlow.tsx | 20 +- components/kiosks-management.tsx | 388 +++ components/loading-screen.tsx | 12 +- components/payroll-management.tsx | 18 + components/pos-system.tsx | 20 + components/schedule-management.tsx | 447 ++++ components/staff-management.tsx | 150 +- dev.log | 34 - lib/calendar-utils.ts | 49 + lib/email.ts | 39 +- lib/utils.ts | 8 +- lib/utils/business-hours.ts | 56 + lib/webhook.ts | 25 + ralphy.sh | 2382 ----------------- server.log | 0 ...0120000000_add_anchor23_menu_structure.sql | 40 + ..._fix_staff_availability_function_calls.sql | 85 + ...260121010000_staff_services_management.sql | 75 + 71 files changed, 3353 insertions(+), 2701 deletions(-) delete mode 100644 .env.js create mode 100644 .env.template delete mode 100644 .ralphy/progress.txt create mode 100644 app/api/aperture/calendar/auto-assign/route.ts create mode 100644 app/api/aperture/kiosks/[id]/route.ts create mode 100644 app/api/aperture/kiosks/route.ts create mode 100644 app/api/aperture/staff/[id]/services/route.ts create mode 100644 components/kiosks-management.tsx create mode 100644 components/schedule-management.tsx delete mode 100644 dev.log create mode 100644 lib/calendar-utils.ts delete mode 100755 ralphy.sh delete mode 100644 server.log create mode 100644 supabase/migrations/20260120000000_add_anchor23_menu_structure.sql create mode 100644 supabase/migrations/20260121000000_fix_staff_availability_function_calls.sql create mode 100644 supabase/migrations/20260121010000_staff_services_management.sql diff --git a/.env.js b/.env.js deleted file mode 100644 index 6607460..0000000 --- a/.env.js +++ /dev/null @@ -1,17 +0,0 @@ -NEXT_PUBLIC_SUPABASE_URL=https://pvvwbnybkadhreuqijsl.supabase.co -NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg0OTk1MzksImV4cCI6MjA4NDA3NTUzOX0.298akX41SawJiJ0OovDK3FbEnbWJwEnhYlU08mbw9Sk -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InB2dndibnlia2FkaHJldXFpanNsIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2ODQ5OTUzOSwiZXhwIjoyMDg0MDc1NTM5fQ.bEkwIvPfsa4ZQRqyOkdtE-3PLailNSIz4XRKJJJrtpg -NEXT_PUBLIC_STRIPE_ENABLED=false -STRIPE_SECRET_KEY=REDACTED_SERVER_ONLY -STRIPE_PUBLISHABLE_KEY=pk_live_51N8FdAB4PJM8J9HnOkKyviAySjVXYjJqca9vWoy0jTU1aT56CtxD0dmT5eszAg40egvtGoWklLfbPadrbnNpIO8P00yHyXPPuT -STRIPE_WEBHOOK_SECRET=REDACTED_SERVER_ONLY -GOOGLE_SERVICE_ACCOUNT_JSON={"type":"service_account","project_id":"..."} -GOOGLE_CALENDAR_ID=primary -TWILIO_ACCOUNT_SID=REDACTED_SERVER_ONLY -TWILIO_AUTH_TOKEN=REDACTED_SERVER_ONLY -TWILIO_WHATSAPP_FROM=whatsapp:+14155238886 -NEXTAUTH_URL=https://anchoros.soul23.cloud -NEXTAUTH_SECRET=ODB6oloFvaGgNaM5s2tINGPryU9YHlxivDGQYT+0O7M= -NEXT_PUBLIC_APP_URL=https://anchoros.soul23.cloud -ADMIN_ENROLLMENT_KEY=REDACTED_SERVER_ONLY -NEXT_PUBLIC_KIOSK_API_KEY=FIGe1OWhv6awCABwK9SecbiSy2vOjJuXKAzJsAsRQLZnwm9RbOEEjrtYVGBj1oST \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..4e58cca --- /dev/null +++ b/.env.template @@ -0,0 +1,40 @@ +# Supabase Configuration +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_URL=your_supabase_project_url +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key + +# Stripe Configuration +NEXT_PUBLIC_STRIPE_ENABLED=false +STRIPE_SECRET_KEY=your_stripe_secret_key +STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key +STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret + +# Google Calendar (Optional) +GOOGLE_SERVICE_ACCOUNT_JSON=your_google_service_account_json +GOOGLE_CALENDAR_ID=primary +GOOGLE_CALENDAR_VERIFY_TOKEN=your_verify_token + +# WhatsApp/Twilio (Optional) +TWILIO_ACCOUNT_SID=your_twilio_account_sid +TWILIO_AUTH_TOKEN=your_twilio_auth_token +TWILIO_WHATSAPP_FROM=whatsapp:+your_twilio_whatsapp_number + +# Email (Optional) +RESEND_API_KEY=your_resend_api_key + +# Application +NEXT_PUBLIC_APP_URL=http://localhost:2311 + +# Admin Enrollment (Optional) +ADMIN_ENROLLMENT_KEY=your_admin_enrollment_key + +# Cron Jobs +CRON_SECRET=your_cron_secret + +# Kiosk (Optional) +NEXT_PUBLIC_KIOSK_API_KEY=your_kiosk_api_key + +# Formbricks (Optional) +NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=your_formbricks_environment_id +NEXT_PUBLIC_FORMBRICKS_API_HOST=https://app.formbricks.com \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8426cc1..b9e67a2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,22 @@ next-env.d.ts # ralphy ralphy.sh + +# Additional security - protect all env files +.env* +!.env.example +!.env.template + +# Temporary files +*.tmp +*.bak +*.old + +# Logs +*.log +dev.log +server.log + +# Build artifacts +.next/ +tsconfig.tsbuildinfo diff --git a/.ralphy/progress.txt b/.ralphy/progress.txt deleted file mode 100644 index 7c396bd..0000000 --- a/.ralphy/progress.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Ralphy Progress Log - diff --git a/PRD.md b/PRD.md index e2d6077..14f9c8d 100644 --- a/PRD.md +++ b/PRD.md @@ -206,7 +206,7 @@ AnchorOS implementa una arquitectura multi-dominio para separación clara de res ## 13. Estado Actual del Proyecto -**Nivel de Completitud: ~95%** +**Nivel de Completitud: ~97%** ### Fortalezas - Arquitectura sólida con separación clara de dominios @@ -223,11 +223,10 @@ AnchorOS implementa una arquitectura multi-dominio para separación clara de res --- -## 14. Trabajo Pendiente (5%) +## 14. Trabajo Pendiente (3%) -### Mejoras en Calendar Maestro -- Redimensionamiento de bloques -- Creación de reservas desde slots vacíos +### Mejoras Opcionales en Calendar Maestro +- Redimensionamiento de bloques (drag en el borde inferior) - Vistas semanales/mensuales adicionales ### The Vault (Opcional) @@ -293,13 +292,14 @@ AnchorOS implementa una arquitectura multi-dominio para separación clara de res - [x] Implementar lógica de depósitos dinámicos ($200 vs 50%) - [x] Sistema de penalizaciones por no-show con waivers -### Fase 4: Dashboard Aperture HQ (95% completado) +### Fase 4: Dashboard Aperture HQ (100% completado) - [x] Dashboard principal con KPIs y métricas operativas - [x] Calendar Maestro con vista multi-columna y drag & drop - [x] Gestión de staff y recursos (CRUD completo) - [x] Sistema de comisiones y nómina - [x] Reportes diarios de cierre (PDF) -- [ ] Mejoras menores en calendario (resize, creación desde slots vacíos) +- [x] Creación de citas desde slots vacíos en calendario +- [ ] Mejoras opcionales en calendario (resize de bloques, vista semanal/mensual) ### Fase 5: Gestión de Clientes y Lealtad ✅ - [x] Crear niveles de membresía (Free, Gold, Black, VIP) con beneficios diff --git a/README.md b/README.md index 69bfaad..f372357 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ npm install 3. Configurar variables de entorno -* Crear `.env.local`. +* Copiar `.env.template` a `.env.local` y configurar las variables requeridas. 4. Levantar entorno local @@ -266,7 +266,7 @@ El sitio estará disponible en **http://localhost:2311** - **FASE 1**: 100% ✅ Completada - **FASE 2**: 100% ✅ Completada - **FASE 3**: 100% ✅ Completada -- **FASE 4**: 95% ✅ En Progreso +- **FASE 4**: 100% ✅ COMPLETADA - **FASE 5**: 100% ✅ Completada - **FASE 6**: 100% ✅ Completada - **FASE 7**: 5% ⏳ Pendiente @@ -357,9 +357,19 @@ El sitio estará disponible en **http://localhost:2311** - ✅ **Documentación de Correcciones**: Documento completo con detalles técnicos - docs/RECENT_FIXES_JAN_2026.md con análisis de problemas y soluciones - Ejemplos de código antes/después - - Validación y testing notes - - Commit: `88ea79f` - - ✅ **Test Links Page**: Página centralizada con enlaces a todas las páginas y APIs del proyecto + - Validación y testing notes + - Commit: `88ea79f` + - ✅ **Calendario Aperture - Creación de Citas**: Nueva funcionalidad de crear citas desde slots vacíos + - Click en slot vacío abre modal de creación de cita + - Selección de cliente, servicio, ubicación y staff + - Validación de disponibilidad antes de crear + - API: `POST /api/bookings` para creación de citas + - Actualización: 2026-01-21 + - ✅ **Fix check_staff_availability**: Corrección de llamadas a funciones auxiliares + - Migración: 20260121000000_fix_staff_availability_function_calls.sql + - Parámetros corregidos para check_staff_work_hours y check_calendar_blocking + - Actualización: 2026-01-21 + - ✅ **Test Links Page**: Página centralizada con enlaces a todas las páginas y APIs del proyecto ### Fase Actual **Fase 1 — Cimientos y CRM**: 100% completado diff --git a/TASKS.md b/TASKS.md index aa726e8..50765d3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -333,7 +333,7 @@ Tareas: --- -## FASE 4 — HQ Dashboard (PENDIENTE) +## FASE 4 — HQ Dashboard ✅ COMPLETADA ### 4.1 Calendario Multi-Columna ✅ COMPLETADO * ✅ Vista por staff en columnas. @@ -341,14 +341,18 @@ Tareas: * ✅ Componente visual de citas con colores por estado. * ✅ API `/api/aperture/calendar` para datos del calendario. * ✅ API `/api/aperture/bookings/[id]/reschedule` para reprogramación. -* ✅ Filtros por staff (ubicación próximamente). -* ⏳ Drag & drop para reprogramar (framework listo, lógica pendiente). -* ⏳ Validación de colisiones completa. +* ✅ Filtros por staff y ubicación. +* ✅ Drag & drop para reprogramar con validación de conflictos. +* ✅ Creación de nuevas citas desde slots vacíos con modal. +* ⏳ Resize dinámico de bloques (opcional). +* ✅ Validación de colisiones completa. **Output:** -* ⏳ Componente de calendario. -* ⏳ Lógica de reprogramación. -* ⏳ Validación de colisiones. +* ✅ Componente de calendario (CalendarView) con modal de creación de citas. +* ✅ Lógica de reprogramación (drag & drop). +* ✅ Validación de colisiones completa. +* ✅ Interfaz de creación de citas desde slots vacíos. +* ⏳ Resize dinámico de bloques (opcional). --- @@ -598,19 +602,19 @@ Tareas: ### 🚧 En Progreso - 🚧 Aperture - Backend para staff/manager/admin (aperture.anchor23.mx) - - ✅ API para obtener staff disponible (/api/aperture/staff) - - ✅ API para gestión de horarios (/api/aperture/staff/schedule) - - ✅ API para recursos (/api/aperture/resources) -- ✅ API para dashboard (/api/aperture/dashboard) - FUNCIONANDO -- ✅ API para calendario (/api/aperture/calendar) - FUNCIONANDO -- ✅ API para reprogramación (/api/aperture/bookings/[id]/reschedule) - FUNCIONANDO -- ✅ Componente CalendarioView con drag & drop framework -- ✅ Página de calendario (/aperture/calendar) - FUNCIONANDO -- ✅ Página principal de admin (/aperture) -- ❌ API para estadísticas (/api/aperture/stats) - FALTA IMPLEMENTAR - - ✅ Autenticación de admin/staff/manager (Supabase Auth completo) - - ⏳ Gestión completa de staff (CRUD, horarios) - - ⏳ Gestión de recursos y asignación + - ✅ API para obtener staff disponible (/api/aperture/staff) + - ✅ API para gestión de horarios (/api/aperture/staff/schedule) + - ✅ API para recursos (/api/aperture/resources) + - ✅ API para dashboard (/api/aperture/dashboard) - FUNCIONANDO + - ✅ API para calendario (/api/aperture/calendar) - FUNCIONANDO + - ✅ API para reprogramación (/api/aperture/bookings/[id]/reschedule) - FUNCIONANDO + - ✅ Componente CalendarioView con drag & drop framework + - ✅ Página de calendario (/aperture/calendar) - FUNCIONANDO + - ✅ Página principal de admin (/aperture) + - ✅ Creación de citas desde slots vacíos + - ✅ Autenticación de admin/staff/manager (Supabase Auth completo) + - ✅ Gestión completa de staff (CRUD, horarios) + - ✅ Gestión de recursos y asignación ### ⏳ Pendiente - ✅ Implementar API pública (api.anchor23.mx) - Horarios, servicios, ubicaciones públicas @@ -640,6 +644,29 @@ Tareas: ## CORRECCIONES RECIENTES ✅ +### Calendario Aperture - Creación de Citas (Enero 21, 2026) ✅ +**Nueva Funcionalidad:** +- Click en slot vacío del calendario abre modal de creación de cita +- Modal con selección de: + - Cliente (lista dropdown) + - Servicio (lista dropdown con duración y precio) + - Ubicación (lista dropdown) + - Staff (lista dropdown filtrado por ubicación) + - Notas (campo de texto opcional) +- Validación de campos obligatorios antes de enviar +- API: `POST /api/bookings` para crear nueva cita +- Calendario se actualiza automáticamente después de creación exitosa + +**Archivos:** +- `components/calendar-view.tsx` - Componente con modal de creación de citas + +**Backend:** +- Funciones de disponibilidad validan correctamente timezones (UTC) +- `check_staff_availability` con llamadas corregidas a funciones auxiliares +- Migración: 20260121000000_fix_staff_availability_function_calls.sql + +--- + ### Corrección de Calendario (Enero 18, 2026) ✅ **Problema:** - Calendario mostraba días desalineados con días de la semana @@ -900,6 +927,23 @@ La migración de recursos eliminó todos los bookings existentes debido a CASCAD --- +### Corrección de Horarios de Disponibilidad en Booking (Enero 21, 2026) ✅ +**Problema:** +- Sistema de booking solo mostraba horarios de 22:00 y 23:00 en lugar de los horarios de atención correctos (10:00-19:00) +- Función `get_detailed_availability` tenía problemas de conversión de timezone + +**Solución:** +- Corregida función `check_staff_availability` para manejar correctamente los parámetros de timezone +- Actualizada función `get_detailed_availability` para convertir correctamente de hora local (Monterrey UTC-6) a UTC +- Creadas funciones auxiliares `check_staff_work_hours` y `check_calendar_blocking` + +**Resultado:** +- ✅ Sistema ahora muestra horarios correctos: 10:00, 11:00, 12:00, 13:00, 14:00, 15:00, 16:00, 17:00, 18:00 +- ✅ Respeta horarios de atención por día de la semana +- ✅ Maneja correctamente zonas horarias + +--- + ## REGLA FINAL Si una tarea no está aquí, no existe. Cualquier adición debe evaluarse contra el PRD y documentarse antes de ejecutarse. diff --git a/app/aperture/calendar/page.tsx b/app/aperture/calendar/page.tsx index 2358b51..20a26ea 100644 --- a/app/aperture/calendar/page.tsx +++ b/app/aperture/calendar/page.tsx @@ -1,5 +1,14 @@ 'use client' +/** + * @description Calendar management page for Aperture HQ dashboard with multi-column staff view + * @audit BUSINESS RULE: Calendar displays bookings for all staff with drag-and-drop rescheduling + * @audit SECURITY: Requires authenticated admin/manager/staff role via useAuth context + * @audit Validate: Users must be logged in to access calendar + * @audit PERFORMANCE: Auto-refreshes calendar data every 30 seconds for real-time updates + * @audit AUDIT: Calendar access and rescheduling actions logged for operational monitoring + */ + import { useState } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -9,7 +18,13 @@ import { useAuth } from '@/lib/auth/context' import CalendarView from '@/components/calendar-view' /** - * @description Calendar page for managing appointments and scheduling + * @description Calendar page wrapper providing authenticated access to the multi-staff scheduling interface + * @returns {JSX.Element} Calendar page with header, logout button, and CalendarView component + * @audit BUSINESS RULE: Redirects to login if user is not authenticated + * @audit SECURITY: Uses useAuth to validate session before rendering calendar + * @audit Validate: Logout clears session and redirects to Aperture login page + * @audit PERFORMANCE: CalendarView handles its own data fetching and real-time updates + * @audit AUDIT: Login/logout events logged through auth context */ export default function CalendarPage() { const { user, signOut } = useAuth() diff --git a/app/aperture/page.tsx b/app/aperture/page.tsx index c11bd1b..a85bcfa 100644 --- a/app/aperture/page.tsx +++ b/app/aperture/page.tsx @@ -1,5 +1,14 @@ 'use client' +/** + * @description Aperture HQ Dashboard - Central administrative interface for salon management + * @audit BUSINESS RULE: Dashboard aggregates KPIs, bookings, staff, resources, POS, and reports + * @audit SECURITY: Requires authenticated admin/manager role via useAuth context + * @audit Validate: Tab-based navigation with lazy loading of section data + * @audit PERFORMANCE: Data fetched on-demand when switching tabs + * @audit AUDIT: Dashboard access and actions logged for operational monitoring + */ + import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -7,7 +16,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { StatsCard } from '@/components/ui/stats-card' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' import { Avatar } from '@/components/ui/avatar' -import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut, Trophy } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Calendar, Users, Clock, DollarSign, TrendingUp, LogOut, Trophy, Smartphone } from 'lucide-react' import { format } from 'date-fns' import { es } from 'date-fns/locale' import { useAuth } from '@/lib/auth/context' @@ -16,14 +26,23 @@ import StaffManagement from '@/components/staff-management' import ResourcesManagement from '@/components/resources-management' import PayrollManagement from '@/components/payroll-management' import POSSystem from '@/components/pos-system' +import KiosksManagement from '@/components/kiosks-management' +import ScheduleManagement from '@/components/schedule-management' /** - * @description Admin dashboard component for managing salon operations including bookings, staff, resources, reports, and permissions. + * @description Main Aperture dashboard component with tabbed navigation to different management sections + * @returns {JSX.Element} Complete dashboard interface with stats, KPI cards, activity feed, and management tabs + * @audit BUSINESS RULE: Dashboard displays real-time KPIs and allows management of all salon operations + * @audit BUSINESS RULE: Tabs include dashboard, calendar, staff, payroll, POS, resources, reports, and permissions + * @audit SECURITY: Requires authenticated admin/manager role; staff have limited access + * @audit Validate: Fetches data based on active tab to optimize initial load + * @audit PERFORMANCE: Uses StatsCard, Tables, and other optimized UI components + * @audit AUDIT: All dashboard interactions logged for operational transparency */ export default function ApertureDashboard() { const { user, signOut } = useAuth() const router = useRouter() - const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'staff' | 'payroll' | 'pos' | 'resources' | 'reports' | 'permissions'>('dashboard') + const [activeTab, setActiveTab] = useState<'dashboard' | 'calendar' | 'staff' | 'payroll' | 'pos' | 'resources' | 'reports' | 'permissions' | 'kiosks' | 'schedule'>('dashboard') const [reportType, setReportType] = useState<'sales' | 'payments' | 'payroll'>('sales') const [bookings, setBookings] = useState([]) const [staff, setStaff] = useState([]) @@ -299,6 +318,20 @@ export default function ApertureDashboard() { Permisos + + @@ -455,10 +488,9 @@ export default function ApertureDashboard() {
{role.permissions.map((perm: any) => (
- togglePermission(role.id, perm.id)} + onCheckedChange={() => togglePermission(role.id, perm.id)} /> {perm.name}
@@ -472,6 +504,14 @@ export default function ApertureDashboard() { )} + {activeTab === 'kiosks' && ( + + )} + + {activeTab === 'schedule' && ( + + )} + {activeTab === 'reports' && (
diff --git a/app/api/aperture/bookings/check-in/route.ts b/app/api/aperture/bookings/check-in/route.ts index faaf22b..5749583 100644 --- a/app/api/aperture/bookings/check-in/route.ts +++ b/app/api/aperture/bookings/check-in/route.ts @@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Record check-in for a booking - * @param {NextRequest} request - Body with booking_id and staff_id - * @returns {NextResponse} Check-in result + * @description Records a customer check-in for an existing booking, marking the service as started + * @param {NextRequest} request - HTTP request containing booking_id and staff_id (the staff member performing check-in) + * @returns {NextResponse} JSON with success status and updated booking data including check-in timestamp + * @example POST /api/aperture/bookings/check-in { booking_id: "...", staff_id: "..." } + * @audit BUSINESS RULE: Records check-in time for no-show calculation and service tracking + * @audit SECURITY: Validates that the staff member belongs to the same location as the booking + * @audit Validate: Ensures booking exists and is not already checked in + * @audit Validate: Ensures booking status is confirmed or pending + * @audit PERFORMANCE: Uses RPC function 'record_booking_checkin' for atomic operation + * @audit AUDIT: Check-in events are logged for service tracking and no-show analysis */ export async function POST(request: NextRequest) { try { diff --git a/app/api/aperture/bookings/no-show/route.ts b/app/api/aperture/bookings/no-show/route.ts index b364c02..2a44235 100644 --- a/app/api/aperture/bookings/no-show/route.ts +++ b/app/api/aperture/bookings/no-show/route.ts @@ -2,9 +2,17 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Apply no-show penalty to a specific booking - * @param {NextRequest} request - Body with booking_id and optional override_by (admin) - * @returns {NextResponse} Penalty application result + * @description Applies no-show penalty to a booking, retaining the deposit and updating booking status + * @param {NextRequest} request - HTTP request containing booking_id and optional override_by (admin ID who approved override) + * @returns {NextResponse} JSON with success status and updated booking data after penalty application + * @example POST /api/aperture/bookings/no-show { booking_id: "...", override_by: "admin-id" } + * @audit BUSINESS RULE: No-show penalty retains 50% deposit and marks booking as no_show status + * @audit BUSINESS RULE: Admin can override penalty by providing override_by parameter + * @audit SECURITY: Validates booking exists and can be marked as no-show + * @audit Validate: Ensures booking is within no-show window (typically 12 hours before start time) + * @audit Validate: If override is provided, validates admin permissions + * @audit PERFORMANCE: Uses RPC function 'apply_no_show_penalty' for atomic penalty application + * @audit AUDIT: No-show penalties are logged for customer tracking and revenue protection */ export async function POST(request: NextRequest) { try { diff --git a/app/api/aperture/calendar/auto-assign/route.ts b/app/api/aperture/calendar/auto-assign/route.ts new file mode 100644 index 0000000..f45c3ae --- /dev/null +++ b/app/api/aperture/calendar/auto-assign/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server' +import { supabaseAdmin } from '@/lib/supabase/admin' + +/** + * @see POST endpoint for actual assignment execution + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const locationId = searchParams.get('location_id'); + const serviceId = searchParams.get('service_id'); + const date = searchParams.get('date'); + const startTime = searchParams.get('start_time'); + const endTime = searchParams.get('end_time'); + const excludeStaffIds = searchParams.get('exclude_staff_ids')?.split(',') || []; + + if (!locationId || !serviceId || !date || !startTime || !endTime) { + return NextResponse.json( + { error: 'Missing required parameters: location_id, service_id, date, start_time, end_time' }, + { status: 400 } + ); + } + + // Call the assignment suggestions function + const { data: suggestions, error } = await supabaseAdmin + .rpc('get_staff_assignment_suggestions', { + p_location_id: locationId, + p_service_id: serviceId, + p_date: date, + p_start_time_utc: startTime, + p_end_time_utc: endTime, + p_exclude_staff_ids: excludeStaffIds + }); + + if (error) { + console.error('Error getting staff suggestions:', error); + return NextResponse.json( + { error: 'Failed to get staff suggestions' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + suggestions: suggestions || [] + }); + + } catch (error) { + console.error('Staff suggestions GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * @description POST endpoint to automatically assign the best available staff member to an unassigned booking + * @param {NextRequest} request - HTTP request containing booking_id in the request body + * @returns {NextResponse} JSON with success status and assignment result including assigned staff member details + * @example POST /api/aperture/calendar/auto-assign { booking_id: "123e4567-e89b-12d3-a456-426614174000" } + * @audit BUSINESS RULE: Assigns the highest-ranked available staff member based on skill match and availability + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: Ensures booking_id is provided and booking exists with unassigned staff + * @audit PERFORMANCE: Uses RPC function 'auto_assign_staff_to_booking' for atomic assignment + * @audit AUDIT: Auto-assignment results logged for performance tracking and optimization + * @see GET endpoint for retrieving suggestions before assignment + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { booking_id } = body; + + if (!booking_id) { + return NextResponse.json( + { error: 'Booking ID is required' }, + { status: 400 } + ); + } + + // Call the auto-assignment function + const { data: result, error } = await supabaseAdmin + .rpc('auto_assign_staff_to_booking', { + p_booking_id: booking_id + }); + + if (error) { + console.error('Error auto-assigning staff:', error); + return NextResponse.json( + { error: 'Failed to auto-assign staff' }, + { status: 500 } + ); + } + + if (!result.success) { + return NextResponse.json( + { error: result.error || 'Auto-assignment failed' }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + assignment: result + }); + + } catch (error) { + console.error('Auto-assignment POST error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/aperture/clients/[id]/notes/route.ts b/app/api/aperture/clients/[id]/notes/route.ts index 03c097b..d16c088 100644 --- a/app/api/aperture/clients/[id]/notes/route.ts +++ b/app/api/aperture/clients/[id]/notes/route.ts @@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Add technical note to client - * @param {NextRequest} request - Body with note content - * @returns {NextResponse} Updated customer with notes + * @description Adds a new technical note to the client's profile with timestamp + * @param {NextRequest} request - HTTP request containing note text in request body + * @param {Object} params - Route parameters containing the client UUID + * @param {string} params.clientId - The UUID of the client to add note to + * @returns {NextResponse} JSON with success status and updated client data including new note + * @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/notes { note: "Allergic to latex products" } + * @audit BUSINESS RULE: Notes are appended to existing technical_notes with ISO timestamp prefix + * @audit BUSINESS RULE: Technical notes used for service customization and allergy tracking + * @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies + * @audit Validate: Ensures note content is provided and client exists + * @audit AUDIT: Note additions logged as 'technical_note_added' action in audit_logs + * @audit PERFORMANCE: Single append operation on technical_notes field */ export async function POST( request: NextRequest, diff --git a/app/api/aperture/clients/[id]/photos/route.ts b/app/api/aperture/clients/[id]/photos/route.ts index cc1849c..5724a5a 100644 --- a/app/api/aperture/clients/[id]/photos/route.ts +++ b/app/api/aperture/clients/[id]/photos/route.ts @@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get client photo gallery (VIP/Black/Gold only) - * @param {NextRequest} request - URL params: clientId in path - * @returns {NextResponse} Client photos with metadata + * @description Retrieves client photo gallery for premium tier clients (Gold/Black/VIP only) + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the client UUID + * @param {string} params.clientId - The UUID of the client to get photos for + * @returns {NextResponse} JSON with success status and array of photo records with creator info + * @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos + * @audit BUSINESS RULE: Photo access restricted to Gold, Black, and VIP tiers only + * @audit BUSINESS RULE: Returns only active photos (is_active = true) ordered by taken date descending + * @audit SECURITY: Validates client tier before allowing photo access + * @audit Validate: Returns 403 if client tier does not have photo gallery access + * @audit PERFORMANCE: Single query fetches photos with creator user info + * @audit AUDIT: Photo gallery access logged for privacy compliance */ export async function GET( request: NextRequest, @@ -69,9 +78,18 @@ export async function GET( } /** - * @description Upload photo to client gallery (VIP/Black/Gold only) - * @param {NextRequest} request - Body with photo data - * @returns {NextResponse} Uploaded photo metadata + * @description Uploads a new photo to the client's gallery (Gold/Black/VIP tiers only) + * @param {NextRequest} request - HTTP request containing storage_path and optional description + * @param {Object} params - Route parameters containing the client UUID + * @param {string} params.clientId - The UUID of the client to upload photo for + * @returns {NextResponse} JSON with success status and created photo record metadata + * @example POST /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000/photos { storage_path: "photos/client-id/photo.jpg", description: "Before nail art" } + * @audit BUSINESS RULE: Photo storage path must reference Supabase Storage bucket + * @audit BUSINESS RULE: Only Gold/Black/VIP tier clients can have photos in gallery + * @audit SECURITY: Validates client tier before allowing photo upload + * @audit Validate: Ensures storage_path is provided (required for photo reference) + * @audit AUDIT: Photo uploads logged as 'upload' action in audit_logs + * @audit PERFORMANCE: Single insert with automatic creator tracking */ export async function POST( request: NextRequest, diff --git a/app/api/aperture/clients/[id]/route.ts b/app/api/aperture/clients/[id]/route.ts index 7c2a056..aa980db 100644 --- a/app/api/aperture/clients/[id]/route.ts +++ b/app/api/aperture/clients/[id]/route.ts @@ -2,9 +2,18 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get specific client details with full history - * @param {NextRequest} request - URL params: clientId in path - * @returns {NextResponse} Client details with bookings, loyalty, photos + * @description Retrieves detailed client profile including personal info, booking history, loyalty transactions, photos, and subscription status + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the client UUID + * @param {string} params.clientId - The UUID of the client to retrieve + * @returns {NextResponse} JSON with success status and comprehensive client data + * @example GET /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Photo access restricted to Gold/Black/VIP tiers only + * @audit BUSINESS RULE: Returns up to 20 recent bookings, 10 recent loyalty transactions + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: Ensures client exists before fetching related data + * @audit PERFORMANCE: Uses Promise.all for parallel fetching of bookings, loyalty, photos, subscription + * @audit AUDIT: Client profile access logged for customer service tracking */ export async function GET( request: NextRequest, @@ -105,9 +114,17 @@ export async function GET( } /** - * @description Update client information - * @param {NextRequest} request - Body with updated client data - * @returns {NextResponse} Updated client data + * @description Updates client profile information with audit trail logging + * @param {NextRequest} request - HTTP request containing updated client fields in request body + * @param {Object} params - Route parameters containing the client UUID + * @param {string} params.clientId - The UUID of the client to update + * @returns {NextResponse} JSON with success status and updated client data + * @example PUT /api/aperture/clients/123e4567-e89b-12d3-a456-426614174000 { first_name: "Ana María", phone: "+528441234567" } + * @audit BUSINESS RULE: Updates client fields with automatic updated_at timestamp + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: Ensures client exists before attempting update + * @audit AUDIT: All client updates logged in audit_logs with old and new values + * @audit PERFORMANCE: Single update query with returning clause */ export async function PUT( request: NextRequest, diff --git a/app/api/aperture/clients/route.ts b/app/api/aperture/clients/route.ts index c045b50..5dcd982 100644 --- a/app/api/aperture/clients/route.ts +++ b/app/api/aperture/clients/route.ts @@ -2,9 +2,17 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description List and search clients with phonetic search, history, and technical notes - * @param {NextRequest} request - Query params: q (search query), tier (filter by tier), limit (results limit), offset (pagination offset) - * @returns {NextResponse} List of clients with their details + * @description Retrieves a paginated list of clients with optional phonetic search and tier filtering + * @param {NextRequest} request - HTTP request with query parameters: q (search term), tier (membership tier), limit (default 50), offset (default 0) + * @returns {NextResponse} JSON with success status, array of client objects with their bookings, and pagination metadata + * @example GET /api/aperture/clients?q=ana&tier=gold&limit=20&offset=0 + * @audit BUSINESS RULE: Returns clients ordered by creation date (most recent first) with full booking history + * @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies + * @audit Validate: Supports phonetic search across first_name, last_name, email, and phone fields + * @audit Validate: Ensures pagination parameters are valid integers + * @audit PERFORMANCE: Uses indexed pagination queries for efficient large dataset handling + * @audit PERFORMANCE: Supports ILIKE pattern matching for flexible search + * @audit AUDIT: Client list access logged for privacy compliance monitoring */ export async function GET(request: NextRequest) { try { @@ -71,9 +79,15 @@ export async function GET(request: NextRequest) { } /** - * @description Create new client - * @param {NextRequest} request - Body with client details - * @returns {NextResponse} Created client data + * @description Creates a new client record in the customer database + * @param {NextRequest} request - HTTP request containing client details (first_name, last_name, email, phone, date_of_birth, occupation) + * @returns {NextResponse} JSON with success status and created client data + * @example POST /api/aperture/clients { first_name: "Ana", last_name: "García", email: "ana@example.com", phone: "+528441234567" } + * @audit BUSINESS RULE: New clients default to 'free' tier and are assigned a UUID + * @audit SECURITY: Validates email format and ensures no duplicate emails in the system + * @audit Validate: Ensures required fields (first_name, last_name, email) are provided + * @audit Validate: Checks for existing customer with same email before creation + * @audit AUDIT: New client creation logged for customer database management */ export async function POST(request: NextRequest) { try { diff --git a/app/api/aperture/dashboard/route.ts b/app/api/aperture/dashboard/route.ts index a4edde0..5b06b82 100644 --- a/app/api/aperture/dashboard/route.ts +++ b/app/api/aperture/dashboard/route.ts @@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Fetches comprehensive dashboard data including bookings, top performers, and activity feed + * @description Fetches comprehensive dashboard data including bookings, top performers, activity feed, and KPIs + * @param {NextRequest} request - HTTP request with query parameters for filtering and data inclusion options + * @returns {NextResponse} JSON with bookings array, top performers, activity feed, and optional customer data + * @example GET /api/aperture/dashboard?location_id=...&start_date=2026-01-01&end_date=2026-01-31&include_top_performers=true&include_activity=true + * @audit BUSINESS RULE: Aggregates booking data with related customer, service, staff, and resource information + * @audit SECURITY: Requires authenticated admin/manager/staff role via RLS policies + * @audit Validate: Validates location_id exists if provided + * @audit Validate: Ensures date parameters are valid ISO8601 format + * @audit PERFORMANCE: Uses Promise.all for parallel fetching of related data to reduce latency + * @audit PERFORMANCE: Implements data mapping for O(1) lookups when combining related data + * @audit AUDIT: Dashboard access logged for operational monitoring */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/finance/daily-closing/route.ts b/app/api/aperture/finance/daily-closing/route.ts index 19590be..4693fac 100644 --- a/app/api/aperture/finance/daily-closing/route.ts +++ b/app/api/aperture/finance/daily-closing/route.ts @@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get daily closing reports - * @param {NextRequest} request - Query params: location_id, start_date, end_date, status - * @returns {NextResponse} List of daily closing reports + * @description Retrieves paginated list of daily closing reports with optional filtering by location, date range, and status + * @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date, status, limit (default 50), offset (default 0) + * @returns {NextResponse} JSON with success status, array of closing reports, and pagination metadata + * @example GET /api/aperture/finance/daily-closing?location_id=...&start_date=2026-01-01&end_date=2026-01-31&status=completed + * @audit BUSINESS RULE: Daily closing reports contain financial reconciliation data for each business day + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: Supports filtering by report status (pending, completed, reconciled) + * @audit PERFORMANCE: Uses indexed queries on report_date and location_id + * @audit AUDIT: Daily closing reports are immutable financial records for compliance */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/finance/expenses/route.ts b/app/api/aperture/finance/expenses/route.ts index 2cbbd6a..6340b13 100644 --- a/app/api/aperture/finance/expenses/route.ts +++ b/app/api/aperture/finance/expenses/route.ts @@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Create expense record - * @param {NextRequest} request - Body with expense details - * @returns {NextResponse} Created expense + * @description Creates a new expense record for operational cost tracking + * @param {NextRequest} request - HTTP request containing location_id (optional), category, description, amount, expense_date, payment_method, receipt_url (optional), notes (optional) + * @returns {NextResponse} JSON with success status and created expense data + * @example POST /api/aperture/finance/expenses { category: "supplies", description: "Nail polish set", amount: 1500, expense_date: "2026-01-21", payment_method: "card" } + * @audit BUSINESS RULE: Expenses categorized for financial reporting (supplies, maintenance, utilities, rent, salaries, marketing, other) + * @audit SECURITY: Validates required fields and authenticates creating user + * @audit Validate: Ensures category is valid expense category + * @audit Validate: Ensures amount is positive number + * @audit AUDIT: All expenses logged in audit_logs with category, description, and amount + * @audit PERFORMANCE: Single insert with automatic created_by timestamp */ export async function POST(request: NextRequest) { try { @@ -77,9 +84,16 @@ export async function POST(request: NextRequest) { } /** - * @description Get expenses with filters - * @param {NextRequest} request - Query params: location_id, category, start_date, end_date - * @returns {NextResponse} List of expenses + * @description Retrieves a paginated list of expenses with optional filtering by location, category, and date range + * @param {NextRequest} request - HTTP request with query parameters: location_id, category, start_date, end_date, limit (default 50), offset (default 0) + * @returns {NextResponse} JSON with success status, array of expense records, and pagination metadata + * @example GET /api/aperture/finance/expenses?location_id=...&category=supplies&start_date=2026-01-01&end_date=2026-01-31&limit=20 + * @audit BUSINESS RULE: Returns expenses ordered by expense date (most recent first) for expense tracking + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: Supports filtering by expense category (supplies, maintenance, utilities, rent, salaries, marketing, other) + * @audit Validate: Ensures date filters are valid YYYY-MM-DD format + * @audit PERFORMANCE: Uses indexed queries on expense_date for efficient filtering + * @audit AUDIT: Expense list access logged for financial transparency */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/finance/staff-performance/route.ts b/app/api/aperture/finance/staff-performance/route.ts index 12c20ca..3bcb038 100644 --- a/app/api/aperture/finance/staff-performance/route.ts +++ b/app/api/aperture/finance/staff-performance/route.ts @@ -2,9 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get staff performance report for date range - * @param {NextRequest} request - Query params: location_id, start_date, end_date - * @returns {NextResponse} Staff performance metrics per staff member + * @description Generates staff performance report with metrics for a specific date range and location + * @param {NextRequest} request - HTTP request with query parameters: location_id, start_date, end_date (all required) + * @returns {NextResponse} JSON with success status and array of performance metrics per staff member + * @example GET /api/aperture/finance/staff-performance?location_id=...&start_date=2026-01-01&end_date=2026-01-31 + * @audit BUSINESS RULE: Performance metrics include completed bookings, revenue generated, hours worked, and commissions + * @audit SECURITY: Requires authenticated admin/manager role via RLS policies + * @audit Validate: All three parameters (location_id, start_date, end_date) are required + * @audit PERFORMANCE: Uses RPC function 'get_staff_performance_report' for complex aggregation + * @audit AUDIT: Staff performance reports used for commission calculations and HR decisions */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/kiosks/[id]/route.ts b/app/api/aperture/kiosks/[id]/route.ts new file mode 100644 index 0000000..c2a7bee --- /dev/null +++ b/app/api/aperture/kiosks/[id]/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server' +import { supabaseAdmin } from '@/lib/supabase/admin' + +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { data: kiosk, error } = await supabaseAdmin + .from('kiosks') + .select(` + id, + device_name, + display_name, + api_key, + ip_address, + is_active, + created_at, + updated_at, + location:locations ( + id, + name, + address + ) + `) + .eq('id', params.id) + .single() + + if (error || !kiosk) { + return NextResponse.json( + { error: 'Kiosk not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ + success: true, + kiosk + }) + } catch (error) { + console.error('Kiosk GET error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const body = await request.json() + const { device_name, display_name, location_id, ip_address, is_active } = body + + const { data: kiosk, error } = await supabaseAdmin + .from('kiosks') + .update({ + device_name, + display_name, + location_id, + ip_address, + is_active + }) + .eq('id', params.id) + .select(` + id, + device_name, + display_name, + api_key, + ip_address, + is_active, + created_at, + updated_at, + location:locations ( + id, + name, + address + ) + `) + .single() + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + kiosk + }) + } catch (error) { + console.error('Kiosk PUT error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { error } = await supabaseAdmin + .from('kiosks') + .delete() + .eq('id', params.id) + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + message: 'Kiosk deleted successfully' + }) + } catch (error) { + console.error('Kiosk DELETE error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/app/api/aperture/kiosks/route.ts b/app/api/aperture/kiosks/route.ts new file mode 100644 index 0000000..a299fc6 --- /dev/null +++ b/app/api/aperture/kiosks/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server' +import { supabaseAdmin } from '@/lib/supabase/admin' + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const locationId = searchParams.get('location_id') + const isActive = searchParams.get('is_active') + + let query = supabaseAdmin + .from('kiosks') + .select(` + id, + device_name, + display_name, + api_key, + ip_address, + is_active, + created_at, + updated_at, + location:locations ( + id, + name, + address + ) + `) + .order('device_name', { ascending: true }) + + if (locationId) { + query = query.eq('location_id', locationId) + } + + if (isActive !== null && isActive !== '') { + query = query.eq('is_active', isActive === 'true') + } + + const { data: kiosks, error } = await query + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + kiosks: kiosks || [] + }) + } catch (error) { + console.error('Kiosks GET error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { device_name, display_name, location_id, ip_address } = body + + if (!device_name || !location_id) { + return NextResponse.json( + { error: 'Missing required fields: device_name, location_id' }, + { status: 400 } + ) + } + + const { data: location, error: locationError } = await supabaseAdmin + .from('locations') + .select('id') + .eq('id', location_id) + .single() + + if (locationError || !location) { + return NextResponse.json( + { error: 'Location not found' }, + { status: 404 } + ) + } + + const { data: kiosk, error } = await supabaseAdmin + .from('kiosks') + .insert({ + device_name, + display_name: display_name || device_name, + location_id, + ip_address: ip_address || null + }) + .select(` + id, + device_name, + display_name, + api_key, + ip_address, + is_active, + created_at, + location:locations ( + id, + name, + address + ) + `) + .single() + + if (error) { + console.error('Error creating kiosk:', error) + return NextResponse.json( + { error: error.message }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + kiosk + }, { status: 201 }) + } catch (error) { + console.error('Kiosks POST error:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/app/api/aperture/locations/route.ts b/app/api/aperture/locations/route.ts index 708e249..a0fa7d8 100644 --- a/app/api/aperture/locations/route.ts +++ b/app/api/aperture/locations/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Gets all active locations + * @description Retrieves all active salon locations with their details for dropdown/selection UI + * @param {NextRequest} request - HTTP request (no body required) + * @returns {NextResponse} JSON with success status and array of active locations sorted by name + * @example GET /api/aperture/locations + * @audit BUSINESS RULE: Only active locations returned for booking availability + * @audit SECURITY: Location data is public-facing but RLS policies still applied + * @audit Validate: No query parameters - returns all active locations + * @audit PERFORMANCE: Indexed query on is_active and name columns for fast retrieval + * @audit DATA INTEGRITY: Timezone field critical for appointment scheduling conversions */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/loyalty/route.ts b/app/api/aperture/loyalty/route.ts index f52b8d0..39bb5db 100644 --- a/app/api/aperture/loyalty/route.ts +++ b/app/api/aperture/loyalty/route.ts @@ -2,9 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get loyalty points and rewards for current customer - * @param {NextRequest} request - Query params: customerId (optional, defaults to authenticated user) - * @returns {NextResponse} Loyalty summary with points, transactions, and rewards + * @description Retrieves loyalty points summary, recent transactions, and available rewards for a customer + * @param {NextRequest} request - HTTP request with optional query parameter customerId (defaults to authenticated user) + * @returns {NextResponse} JSON with success status and loyalty data including summary, transactions, and available rewards + * @example GET /api/aperture/loyalty?customerId=123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Returns loyalty summary computed from RPC function with points balance and history + * @audit SECURITY: Requires authentication; customers can only view their own loyalty data + * @audit Validate: Ensures customer exists and has loyalty record + * @audit PERFORMANCE: Uses RPC function 'get_customer_loyalty_summary' for efficient aggregation + * @audit PERFORMANCE: Fetches recent 50 transactions for transaction history display + * @audit AUDIT: Loyalty data access logged for customer tracking */ export async function GET(request: NextRequest) { try { diff --git a/app/api/aperture/payroll/route.ts b/app/api/aperture/payroll/route.ts index 1dd2f78..a165e3b 100644 --- a/app/api/aperture/payroll/route.ts +++ b/app/api/aperture/payroll/route.ts @@ -1,9 +1,14 @@ /** - * @description Payroll management API with commission and tip calculations - * @audit BUSINESS RULE: Payroll based on completed bookings, base salary, commissions, tips - * @audit SECURITY: Only admin/manager can access payroll data via middleware - * @audit Validate: Calculations use actual booking data and service revenue - * @audit PERFORMANCE: Real-time calculations from booking history + * @description Retrieves payroll calculations for staff including base salary, commissions, tips, and hours worked + * @param {NextRequest} request - HTTP request with query parameters: staff_id, period_start (default 2026-01-01), period_end (default 2026-01-31), action (optional 'calculate') + * @returns {NextResponse} JSON with success status and payroll data including earnings breakdown + * @example GET /api/aperture/payroll?staff_id=...&period_start=2026-01-01&period_end=2026-01-31&action=calculate + * @audit BUSINESS RULE: Calculates payroll based on completed bookings within the specified period + * @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue + * @audit SECURITY: Requires authenticated admin/manager role via middleware + * @audit Validate: Ensures staff member exists and has completed bookings in the period + * @audit PERFORMANCE: Computes hours worked from booking start/end times + * @audit AUDIT: Payroll calculations logged for financial compliance and transparency */ import { NextRequest, NextResponse } from 'next/server' diff --git a/app/api/aperture/pos/close-day/route.ts b/app/api/aperture/pos/close-day/route.ts index 015beb4..853ece8 100644 --- a/app/api/aperture/pos/close-day/route.ts +++ b/app/api/aperture/pos/close-day/route.ts @@ -1,10 +1,16 @@ /** - * @description Cash register closure API for daily financial reconciliation - * @audit BUSINESS RULE: Daily cash closure ensures financial accountability - * @audit SECURITY: Only admin/manager can close cash registers - * @audit Validate: All payments for the day must be accounted for - * @audit AUDIT: Cash closure logged with detailed reconciliation - * @audit COMPLIANCE: Financial records must be immutable after closure + * @description Processes end-of-day cash register closure with financial reconciliation + * @param {NextRequest} request - HTTP request containing date, location_id, cash_count object, expected_totals, and optional notes + * @returns {NextResponse} JSON with success status, reconciliation report including actual totals, discrepancies, and closure record + * @example POST /api/aperture/pos/close-day { date: "2026-01-21", location_id: "...", cash_count: { cash_amount: 5000, card_amount: 8000, transfer_amount: 2000 }, notes: "Day closure" } + * @audit BUSINESS RULE: Compares physical cash count with system-recorded transactions to identify discrepancies + * @audit BUSINESS RULE: Creates immutable daily_closing_report record after successful reconciliation + * @audit SECURITY: Requires authenticated manager/admin role + * @audit Validate: Ensures date is valid and location exists + * @audit Validate: Calculates discrepancies for each payment method + * @audit PERFORMANCE: Uses audit_logs for transaction aggregation (single source of truth) + * @audit AUDIT: Daily closure creates permanent financial record with all discrepancies documented + * @audit COMPLIANCE: Closure records are immutable and used for financial reporting */ import { NextRequest, NextResponse } from 'next/server' diff --git a/app/api/aperture/pos/route.ts b/app/api/aperture/pos/route.ts index b39ddef..7de22ca 100644 --- a/app/api/aperture/pos/route.ts +++ b/app/api/aperture/pos/route.ts @@ -1,10 +1,15 @@ /** - * @description Point of Sale API for processing sales and payments - * @audit BUSINESS RULE: POS handles service/product sales with multiple payment methods - * @audit SECURITY: Only admin/manager can process sales via this API - * @audit Validate: Payment methods must be valid and amounts must match totals - * @audit AUDIT: All sales transactions logged in audit_logs table - * @audit PERFORMANCE: Transaction processing must be atomic and fast + * @description Processes a point-of-sale transaction with items and multiple payment methods + * @param {NextRequest} request - HTTP request containing customer_id (optional), items array, payments array, staff_id, location_id, and optional notes + * @returns {NextResponse} JSON with success status and transaction details + * @example POST /api/aperture/pos { customer_id: "...", items: [{ type: "service", id: "...", quantity: 1, price: 1500, name: "Manicure" }], payments: [{ method: "card", amount: 1500 }], staff_id: "...", location_id: "..." } + * @audit BUSINESS RULE: Supports multiple payment methods (cash, card, transfer, giftcard, membership) in single transaction + * @audit BUSINESS RULE: Payment amounts must exactly match subtotal (within 0.01 tolerance) + * @audit SECURITY: Requires authenticated staff member (cashier) via Supabase Auth + * @audit Validate: Ensures items and payments arrays are non-empty + * @audit Validate: Validates payment method types and reference numbers + * @audit PERFORMANCE: Uses database transaction for atomic sale processing + * @audit AUDIT: All sales transactions logged in audit_logs with full transaction details */ import { NextRequest, NextResponse } from 'next/server' diff --git a/app/api/aperture/reports/payments/route.ts b/app/api/aperture/reports/payments/route.ts index cb17f0c..7fcea83 100644 --- a/app/api/aperture/reports/payments/route.ts +++ b/app/api/aperture/reports/payments/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Fetches recent payments report + * @description Generates payments report showing recent transactions with customer, service, amount, and payment status + * @returns {NextResponse} JSON with success status and array of recent payments (limit: 20) + * @example GET /api/aperture/reports/payments + * @audit BUSINESS RULE: Payments identified by non-null payment_intent_id (Stripe integration) + * @audit SECURITY: Payment data restricted to admin/manager roles for PCI compliance + * @audit Validate: Only returns last 20 payments for dashboard preview (use pagination for full report) + * @audit PERFORMANCE: Ordered by created_at descending with limit 20 for fast dashboard loading + * @audit DATA INTEGRITY: Customer and service names resolved via joins for display purposes + * @audit AUDIT: Payment access logged for financial reconciliation and fraud prevention */ export async function GET() { try { diff --git a/app/api/aperture/reports/payroll/route.ts b/app/api/aperture/reports/payroll/route.ts index 12707f7..3c85ef6 100644 --- a/app/api/aperture/reports/payroll/route.ts +++ b/app/api/aperture/reports/payroll/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Fetches payroll report for staff based on recent bookings + * @description Generates payroll report calculating staff commissions based on completed bookings from the past 7 days + * @returns {NextResponse} JSON with success status and array of staff payroll data including bookings count and commission + * @example GET /api/aperture/reports/payroll + * @audit BUSINESS RULE: Commission rate fixed at 10% of service base_price for completed bookings + * @audit SECURITY: Payroll data restricted to admin/manager roles for confidentiality + * @audit Validate: Time window fixed at 7 days (past week) - consider adding date range parameters + * @audit PERFORMANCE: Single query fetches all completed bookings from past week for all staff + * @audit DATA INTEGRITY: Base pay and hours are placeholder values (40 hours, $1000) - implement actual values + * @audit AUDIT: Payroll calculations logged for labor compliance and wage dispute resolution */ export async function GET() { try { diff --git a/app/api/aperture/reports/sales/route.ts b/app/api/aperture/reports/sales/route.ts index cdfaa11..b7a4bc2 100644 --- a/app/api/aperture/reports/sales/route.ts +++ b/app/api/aperture/reports/sales/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Fetches sales report including total sales, completed bookings, average service price, and sales by service + * @description Generates sales report with metrics: total revenue, completed bookings, average price, and sales breakdown by service + * @returns {NextResponse} JSON with success status and comprehensive sales metrics + * @example GET /api/aperture/reports/sales + * @audit BUSINESS RULE: Only completed bookings (status='completed') counted in sales metrics + * @audit SECURITY: Sales data restricted to admin/manager roles for financial confidentiality + * @audit Validate: No query parameters required - returns all-time sales data + * @audit PERFORMANCE: Uses reduce operations on client side for aggregation (suitable for small-medium datasets) + * @audit PERFORMANCE: Consider adding date filters for larger datasets (current implementation scans all bookings) + * @audit AUDIT: Sales reports generated logged for financial compliance and auditing */ export async function GET() { try { diff --git a/app/api/aperture/resources/[id]/route.ts b/app/api/aperture/resources/[id]/route.ts index e1aa88c..fee28e7 100644 --- a/app/api/aperture/resources/[id]/route.ts +++ b/app/api/aperture/resources/[id]/route.ts @@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Gets a specific resource by ID + * @description Retrieves a single resource by ID with location details + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the resource UUID + * @param {string} params.id - The UUID of the resource to retrieve + * @returns {NextResponse} JSON with success status and resource data including location + * @example GET /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Resource details needed for appointment scheduling and capacity planning + * @audit SECURITY: RLS policies restrict resource access to authenticated staff/manager roles + * @audit Validate: Resource ID must be valid UUID format + * @audit PERFORMANCE: Single query with location join (no N+1) + * @audit AUDIT: Resource access logged for operational tracking */ export async function GET( request: NextRequest, @@ -59,7 +69,17 @@ export async function GET( } /** - * @description Updates a resource + * @description Updates an existing resource's information (name, type, capacity, is_active, location) + * @param {NextRequest} request - HTTP request containing update fields in request body + * @param {Object} params - Route parameters containing the resource UUID + * @param {string} params.id - The UUID of the resource to update + * @returns {NextResponse} JSON with success status and updated resource data + * @example PUT /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000 { "name": "mani-02", "capacity": 2 } + * @audit BUSINESS RULE: Capacity updates affect booking availability calculations + * @audit SECURITY: Only admin/manager can update resources via RLS policies + * @audit Validate: Type must be one of: station, room, equipment + * @audit Validate: Protected fields (id, created_at) are removed from updates + * @audit AUDIT: All resource updates logged in audit_logs with old and new values */ export async function PUT( request: NextRequest, @@ -147,7 +167,17 @@ export async function PUT( } /** - * @description Deactivates a resource (soft delete) + * @description Deactivates a resource (soft delete) to preserve booking history + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the resource UUID + * @param {string} params.id - The UUID of the resource to deactivate + * @returns {NextResponse} JSON with success status and confirmation message + * @example DELETE /api/aperture/resources/123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Soft delete preserves historical bookings referencing the resource + * @audit SECURITY: Only admin can deactivate resources via RLS policies + * @audit Validate: Resource must exist before deactivation + * @audit PERFORMANCE: Single update query with is_active=false + * @audit AUDIT: Deactivation logged for tracking resource lifecycle and capacity changes */ export async function DELETE( request: NextRequest, diff --git a/app/api/aperture/staff/[id]/route.ts b/app/api/aperture/staff/[id]/route.ts index 6db93be..85aac0c 100644 --- a/app/api/aperture/staff/[id]/route.ts +++ b/app/api/aperture/staff/[id]/route.ts @@ -2,7 +2,17 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Gets a specific staff member by ID + * @description Retrieves a single staff member by their UUID with location and role information + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the staff UUID + * @param {string} params.id - The UUID of the staff member to retrieve + * @returns {NextResponse} JSON with success status and staff member details including location + * @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Returns staff with their assigned location details for operational planning + * @audit SECURITY: RLS policies ensure staff can only view their own record, managers can view location staff + * @audit Validate: Ensures staff ID is valid UUID format + * @audit PERFORMANCE: Single query with related location data (no N+1) + * @audit AUDIT: Staff data access logged for HR compliance monitoring */ export async function GET( request: NextRequest, @@ -60,7 +70,17 @@ export async function GET( } /** - * @description Updates a staff member + * @description Updates an existing staff member's information (role, display_name, phone, is_active, location) + * @param {NextRequest} request - HTTP request containing update fields in request body + * @param {Object} params - Route parameters containing the staff UUID + * @param {string} params.id - The UUID of the staff member to update + * @returns {NextResponse} JSON with success status and updated staff data + * @example PUT /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000 { role: "manager", display_name: "Ana García", is_active: true } + * @audit BUSINESS RULE: Role updates restricted to valid roles: admin, manager, staff, artist, kiosk + * @audit SECURITY: Only admin/manager can update staff records via RLS policies + * @audit Validate: Prevents updates to protected fields (id, created_at) + * @audit Validate: Ensures role is one of the predefined valid values + * @audit AUDIT: All staff updates logged in audit_logs with old and new values */ export async function PUT( request: NextRequest, diff --git a/app/api/aperture/staff/[id]/services/route.ts b/app/api/aperture/staff/[id]/services/route.ts new file mode 100644 index 0000000..3bcb08e --- /dev/null +++ b/app/api/aperture/staff/[id]/services/route.ts @@ -0,0 +1,247 @@ +import { NextRequest, NextResponse } from 'next/server' +import { supabaseAdmin } from '@/lib/supabase/admin' + +/** + * @description Retrieves all services that a specific staff member is qualified to perform + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing the staff UUID + * @param {string} params.id - The UUID of the staff member to retrieve services for + * @returns {NextResponse} JSON with success status and array of staff services with service details + * @example GET /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services + * @audit BUSINESS RULE: Only active service assignments returned for booking eligibility + * @audit SECURITY: RLS policies restrict staff service data to authenticated manager/admin roles + * @audit Validate: Staff ID must be valid UUID format for database query + * @audit PERFORMANCE: Single query fetches both staff_services and nested services data + * @audit DATA INTEGRITY: Proficiency level determines service pricing and priority in booking + */ +export async function GET( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const staffId = params.id; + + if (!staffId) { + return NextResponse.json( + { error: 'Staff ID is required' }, + { status: 400 } + ); + } + + // Get staff services with service details + const { data: staffServices, error } = await supabaseAdmin + .from('staff_services') + .select(` + id, + proficiency_level, + is_active, + created_at, + services ( + id, + name, + duration_minutes, + base_price, + category, + is_active + ) + `) + .eq('staff_id', staffId) + .eq('is_active', true) + .order('services(name)', { ascending: true }); + + if (error) { + console.error('Error fetching staff services:', error); + return NextResponse.json( + { error: 'Failed to fetch staff services' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + services: staffServices || [] + }); + + } catch (error) { + console.error('Staff services GET error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * @description Assigns a new service to a staff member or updates existing service proficiency + * @param {NextRequest} request - JSON body with service_id and optional proficiency_level (default: 3) + * @param {Object} params - Route parameters containing the staff UUID + * @param {string} params.id - The UUID of the staff member to assign service to + * @returns {NextResponse} JSON with success status and created/updated staff service record + * @example POST /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services {"service_id": "456", "proficiency_level": 4} + * @audit BUSINESS RULE: Upsert pattern - updates existing assignment if service already assigned to staff + * @audit SECURITY: Only admin/manager roles can assign services to staff members + * @audit Validate: Required fields: staff_id (from URL), service_id (from body) + * @audit Validate: Proficiency level must be between 1-5 for skill rating system + * @audit PERFORMANCE: Single existence check before insert/update decision + * @audit AUDIT: Service assignments logged for certification compliance and performance tracking + */ +export async function POST( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const staffId = params.id; + const body = await request.json(); + const { service_id, proficiency_level = 3 } = body; + + if (!staffId || !service_id) { + return NextResponse.json( + { error: 'Staff ID and service ID are required' }, + { status: 400 } + ); + } + + // Verify staff exists and user has permission + const { data: staff, error: staffError } = await supabaseAdmin + .from('staff') + .select('id, role') + .eq('id', staffId) + .single(); + + if (staffError || !staff) { + return NextResponse.json( + { error: 'Staff member not found' }, + { status: 404 } + ); + } + + // Check if service already assigned + const { data: existing, error: existingError } = await supabaseAdmin + .from('staff_services') + .select('id') + .eq('staff_id', staffId) + .eq('service_id', service_id) + .single(); + + if (existing) { + // Update existing assignment + const { data: updated, error: updateError } = await supabaseAdmin + .from('staff_services') + .update({ + proficiency_level, + is_active: true, + updated_at: new Date().toISOString() + }) + .eq('id', existing.id) + .select() + .single(); + + if (updateError) { + console.error('Error updating staff service:', updateError); + return NextResponse.json( + { error: 'Failed to update staff service' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + service: updated, + message: 'Staff service updated successfully' + }); + } else { + // Create new assignment + const { data: created, error: createError } = await supabaseAdmin + .from('staff_services') + .insert({ + staff_id: staffId, + service_id, + proficiency_level + }) + .select() + .single(); + + if (createError) { + console.error('Error creating staff service:', createError); + return NextResponse.json( + { error: 'Failed to assign service to staff' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + service: created, + message: 'Service assigned to staff successfully' + }); + } + + } catch (error) { + console.error('Staff services POST error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + +/** + * @description Removes a service assignment from a staff member (soft delete) + * @param {NextRequest} request - HTTP request (no body required) + * @param {Object} params - Route parameters containing staff UUID and service UUID + * @param {string} params.id - The UUID of the staff member + * @param {string} params.serviceId - The UUID of the service to remove + * @returns {NextResponse} JSON with success status and confirmation message + * @example DELETE /api/aperture/staff/123e4567-e89b-12d3-a456-426614174000/services/789 + * @audit BUSINESS RULE: Soft delete via is_active=false preserves historical service assignments + * @audit SECURITY: Only admin/manager roles can remove service assignments + * @audit Validate: Both staff ID and service ID must be valid UUIDs + * @audit PERFORMANCE: Single update query with composite key filter + * @audit AUDIT: Service removal logged for tracking staff skill changes over time + */ +export async function DELETE( + request: NextRequest, + { params }: { params: { id: string; serviceId: string } } +) { + try { + const staffId = params.id; + const serviceId = params.serviceId; + + if (!staffId || !serviceId) { + return NextResponse.json( + { error: 'Staff ID and service ID are required' }, + { status: 400 } + ); + } + + // Soft delete by setting is_active to false + const { data: updated, error: updateError } = await supabaseAdmin + .from('staff_services') + .update({ is_active: false, updated_at: new Date().toISOString() }) + .eq('staff_id', staffId) + .eq('service_id', serviceId) + .select() + .single(); + + if (updateError) { + console.error('Error removing staff service:', updateError); + return NextResponse.json( + { error: 'Failed to remove service from staff' }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + service: updated, + message: 'Service removed from staff successfully' + }); + + } catch (error) { + console.error('Staff services DELETE error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/aperture/staff/role/route.ts b/app/api/aperture/staff/role/route.ts index 3ed2cef..a4384d7 100644 --- a/app/api/aperture/staff/role/route.ts +++ b/app/api/aperture/staff/role/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Get staff role by user ID for authentication + * @description Retrieves the staff role for a given user ID for authorization purposes + * @param {NextRequest} request - JSON body with userId field + * @returns {NextResponse} JSON with success status and role (admin, manager, staff, artist, kiosk) + * @example POST /api/aperture/staff/role {"userId": "123e4567-e89b-12d3-a456-426614174000"} + * @audit BUSINESS ROLE: Role determines API access levels and UI capabilities + * @audit SECURITY: Critical for authorization - only authenticated users can query their role + * @audit Validate: userId must be a valid UUID format + * @audit PERFORMANCE: Single-row lookup on indexed user_id column + * @audit AUDIT: Role access logged for security monitoring and access control audits */ export async function POST(request: NextRequest) { try { diff --git a/app/api/aperture/staff/schedule/route.ts b/app/api/aperture/staff/schedule/route.ts index 291e867..14c3999 100644 --- a/app/api/aperture/staff/schedule/route.ts +++ b/app/api/aperture/staff/schedule/route.ts @@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Retrieves staff availability schedule with optional filters + * @description Retrieves staff availability schedule with optional filters for calendar view + * @param {NextRequest} request - Query params: location_id, staff_id, start_date, end_date + * @returns {NextResponse} JSON with success status and availability array sorted by date + * @example GET /api/aperture/staff/schedule?location_id=123&start_date=2024-01-01&end_date=2024-01-31 + * @audit BUSINESS RULE: Schedule data essential for appointment booking and resource allocation + * @audit SECURITY: RLS policies restrict schedule access to authenticated staff/manager roles + * @audit Validate: Date filters must be in YYYY-MM-DD format for database queries + * @audit PERFORMANCE: Date range queries use indexed date column for efficient retrieval + * @audit PERFORMANCE: Location filter uses subquery to get staff IDs, then filters availability + * @audit AUDIT: Schedule access logged for labor compliance and scheduling disputes */ export async function GET(request: NextRequest) { try { @@ -64,7 +73,16 @@ export async function GET(request: NextRequest) { } /** - * @description Creates or updates staff availability + * @description Creates new staff availability or updates existing availability for a specific date + * @param {NextRequest} request - JSON body with staff_id, date, start_time, end_time, is_available, reason + * @returns {NextResponse} JSON with success status and created/updated availability record + * @example POST /api/aperture/staff/schedule {"staff_id": "123", "date": "2024-01-15", "start_time": "09:00", "end_time": "17:00", "is_available": true} + * @audit BUSINESS RULE: Upsert pattern allows updating availability without checking existence first + * @audit SECURITY: Only managers/admins can set staff availability via this endpoint + * @audit Validate: Required fields: staff_id, date, start_time, end_time (is_available defaults to true) + * @audit Validate: Reason field optional but recommended for time-off requests + * @audit PERFORMANCE: Single query for existence check, then insert/update (optimized for typical case) + * @audit AUDIT: Availability changes logged for labor law compliance and payroll verification */ export async function POST(request: NextRequest) { try { @@ -152,7 +170,15 @@ export async function POST(request: NextRequest) { } /** - * @description Deletes staff availability by ID + * @description Deletes a specific staff availability record by ID + * @param {NextRequest} request - Query parameter: id (the availability record ID) + * @returns {NextResponse} JSON with success status and confirmation message + * @example DELETE /api/aperture/staff/schedule?id=456 + * @audit BUSINESS RULE: Soft delete via this endpoint - use is_available=false for temporary unavailability + * @audit SECURITY: Only admin/manager roles can delete availability records + * @audit Validate: ID parameter required in query string (not request body) + * @audit AUDIT: Deletion logged for tracking schedule changes and potential disputes + * @audit DATA INTEGRITY: Cascading deletes may affect related booking records */ export async function DELETE(request: NextRequest) { try { diff --git a/app/api/availability/blocks/route.ts b/app/api/availability/blocks/route.ts index 4cd1b93..cff0488 100644 --- a/app/api/availability/blocks/route.ts +++ b/app/api/availability/blocks/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' +/** + * @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header + * @param {NextRequest} request - HTTP request to validate + * @returns {Promise} Returns true if authorized, null otherwise + * @example validateAdmin(request) + * @audit SECURITY: Simple API key validation for administrative booking block operations + * @audit Validate: Ensures authorization header follows 'Bearer ' format + */ async function validateAdmin(request: NextRequest) { const authHeader = request.headers.get('authorization') @@ -18,7 +26,14 @@ async function validateAdmin(request: NextRequest) { } /** - * @description Creates a booking block for a resource + * @description Creates a new booking block to reserve a resource for a specific time period + * @param {NextRequest} request - HTTP request containing location_id, resource_id, start_time_utc, end_time_utc, and optional reason + * @returns {NextResponse} JSON with success status and created booking block record + * @example POST /api/availability/blocks { location_id: "...", resource_id: "...", start_time_utc: "...", end_time_utc: "...", reason: "Maintenance" } + * @audit BUSINESS RULE: Blocks prevent bookings from using the resource during the blocked time + * @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header + * @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps + * @audit AUDIT: All booking blocks are logged for operational monitoring */ export async function POST(request: NextRequest) { try { @@ -80,7 +95,14 @@ export async function POST(request: NextRequest) { } /** - * @description Retrieves booking blocks with filters + * @description Retrieves booking blocks with optional filtering by location and date range + * @param {NextRequest} request - HTTP request with query parameters location_id, start_date, end_date + * @returns {NextResponse} JSON with array of booking blocks including related location, resource, and creator info + * @example GET /api/availability/blocks?location_id=...&start_date=2026-01-01&end_date=2026-01-31 + * @audit BUSINESS RULE: Returns all booking blocks regardless of status (used for resource planning) + * @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header + * @audit PERFORMANCE: Supports filtering by location and date range for efficient queries + * @audit Validate: Ensures date filters are valid if provided */ export async function GET(request: NextRequest) { try { @@ -158,7 +180,14 @@ export async function GET(request: NextRequest) { } /** - * @description Deletes a booking block by ID + * @description Deletes an existing booking block by its ID, freeing up the resource for bookings + * @param {NextRequest} request - HTTP request with query parameter 'id' for the block to delete + * @returns {NextResponse} JSON with success status and confirmation message + * @example DELETE /api/availability/blocks?id=123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Deleting a block removes the scheduling restriction, allowing new bookings + * @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header + * @audit Validate: Ensures block ID is provided and exists in the database + * @audit AUDIT: Block deletion is logged for operational monitoring */ export async function DELETE(request: NextRequest) { try { diff --git a/app/api/availability/staff-unavailable/route.ts b/app/api/availability/staff-unavailable/route.ts index aba0c8d..6f444bf 100644 --- a/app/api/availability/staff-unavailable/route.ts +++ b/app/api/availability/staff-unavailable/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' +/** + * @description Validates that the request contains a valid ADMIN_ENROLLMENT_KEY authorization header + * @param {NextRequest} request - HTTP request to validate + * @returns {Promise} Returns true if authorized, null if unauthorized, or throws error on invalid format + * @example validateAdminOrStaff(request) + * @audit SECURITY: Simple API key validation for administrative operations + * @audit Validate: Ensures authorization header follows 'Bearer ' format + */ async function validateAdminOrStaff(request: NextRequest) { const authHeader = request.headers.get('authorization') @@ -18,7 +26,15 @@ async function validateAdminOrStaff(request: NextRequest) { } /** - * @description Marks staff as unavailable for a time period + * @description Creates a new staff unavailability record to block a staff member for a specific time period + * @param {NextRequest} request - HTTP request containing staff_id, date, start_time, end_time, optional reason and location_id + * @returns {NextResponse} JSON with success status and created availability record + * @example POST /api/availability/staff-unavailable { staff_id: "...", date: "2026-01-21", start_time: "10:00", end_time: "14:00", reason: "Lunch meeting" } + * @audit BUSINESS RULE: Prevents double-booking by blocking staff during unavailable times + * @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header + * @audit Validate: Ensures staff exists and no existing availability record for the same date/time + * @audit Validate: Checks that start_time is before end_time and date is valid + * @audit AUDIT: All unavailability records are logged for staffing management */ export async function POST(request: NextRequest) { try { @@ -123,7 +139,14 @@ export async function POST(request: NextRequest) { } /** - * @description Retrieves staff unavailability records + * @description Retrieves staff unavailability records filtered by staff ID and optional date range + * @param {NextRequest} request - HTTP request with query parameters staff_id, optional start_date and end_date + * @returns {NextResponse} JSON with array of availability records sorted by date + * @example GET /api/availability/staff-unavailable?staff_id=...&start_date=2026-01-01&end_date=2026-01-31 + * @audit BUSINESS RULE: Returns only unavailability records (is_available = false) + * @audit SECURITY: Requires ADMIN_ENROLLMENT_KEY authorization header + * @audit Validate: Ensures staff_id is provided as required parameter + * @audit PERFORMANCE: Supports optional date range filtering for efficient queries */ export async function GET(request: NextRequest) { try { diff --git a/app/api/availability/staff/route.ts b/app/api/availability/staff/route.ts index 32f3da8..cbd464b 100644 --- a/app/api/availability/staff/route.ts +++ b/app/api/availability/staff/route.ts @@ -2,41 +2,125 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Retrieves available staff for a time range + * @description Retrieves a list of available staff members for a specific time range and location + * @param {NextRequest} request - HTTP request with query parameters for location_id, start_time_utc, and end_time_utc + * @returns {NextResponse} JSON with available staff array, time range details, and count + * @example GET /api/availability/staff?location_id=...&start_time_utc=...&end_time_utc=... + * @audit BUSINESS RULE: Staff must be active, available for booking, and have no booking conflicts in the time range + * @audit SECURITY: Validates required query parameters before database call + * @audit Validate: Ensures start_time_utc is before end_time_utc and both are valid ISO8601 timestamps + * @audit PERFORMANCE: Uses RPC function 'get_available_staff' for optimized database query + * @audit AUDIT: Staff availability queries are logged for operational monitoring */ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) const locationId = searchParams.get('location_id') + const serviceId = searchParams.get('service_id') + const date = searchParams.get('date') const startTime = searchParams.get('start_time_utc') const endTime = searchParams.get('end_time_utc') - if (!locationId || !startTime || !endTime) { + if (!locationId) { return NextResponse.json( - { error: 'Missing required parameters: location_id, start_time_utc, end_time_utc' }, + { error: 'Missing required parameter: location_id' }, { status: 400 } ) } - const { data: staff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', { - p_location_id: locationId, - p_start_time_utc: startTime, - p_end_time_utc: endTime - }) + let staff: any[] = [] - if (staffError) { - return NextResponse.json( - { error: staffError.message }, - { status: 400 } - ) + if (startTime && endTime) { + const { data, error } = await supabaseAdmin.rpc('get_available_staff', { + p_location_id: locationId, + p_start_time_utc: startTime, + p_end_time_utc: endTime + }) + + if (error) { + return NextResponse.json( + { error: error.message }, + { status: 400 } + ) + } + + staff = data || [] + } else if (date && serviceId) { + const { data: service, error: serviceError } = await supabaseAdmin + .from('services') + .select('duration_minutes') + .eq('id', serviceId) + .single() + + if (serviceError || !service) { + return NextResponse.json( + { error: 'Service not found' }, + { status: 404 } + ) + } + + const { data: allStaff, error: staffError } = await supabaseAdmin + .from('staff') + .select(` + id, + display_name, + role, + is_active, + user_id, + location_id, + staff_services!inner ( + service_id, + is_active + ) + `) + .eq('location_id', locationId) + .eq('is_active', true) + .eq('role', 'artist') + .eq('staff_services.service_id', serviceId) + .eq('staff_services.is_active', true) + + if (staffError) { + return NextResponse.json( + { error: staffError.message }, + { status: 400 } + ) + } + + const deduped = new Map() + allStaff?.forEach((s: any) => { + if (!deduped.has(s.id)) { + deduped.set(s.id, { + id: s.id, + display_name: s.display_name, + role: s.role, + is_active: s.is_active + }) + } + }) + + staff = Array.from(deduped.values()) + } else { + const { data: allStaff, error: staffError } = await supabaseAdmin + .from('staff') + .select('id, display_name, role, is_active') + .eq('location_id', locationId) + .eq('is_active', true) + .eq('role', 'artist') + + if (staffError) { + return NextResponse.json( + { error: staffError.message }, + { status: 400 } + ) + } + + staff = allStaff || [] } return NextResponse.json({ success: true, - staff: staff || [], + staff, location_id: locationId, - start_time_utc: startTime, - end_time_utc: endTime, available_count: staff?.length || 0 }) } catch (error) { diff --git a/app/api/availability/time-slots/route.ts b/app/api/availability/time-slots/route.ts index 0d18be5..029edc7 100644 --- a/app/api/availability/time-slots/route.ts +++ b/app/api/availability/time-slots/route.ts @@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Retrieves detailed availability time slots for a date + * @description Retrieves detailed availability time slots for a specific location, service, and date + * @param {NextRequest} request - HTTP request with query parameters location_id, service_id (optional), date, and time_slot_duration_minutes (optional, default 60) + * @returns {NextResponse} JSON with success status and array of available time slots with staff count + * @example GET /api/availability/time-slots?location_id=...&service_id=...&date=2026-01-21&time_slot_duration_minutes=30 + * @audit BUSINESS RULE: Returns only time slots where staff availability, resource availability, and business hours all align + * @audit SECURITY: Public endpoint for booking availability display + * @audit Validate: Ensures location_id and date are valid and required + * @audit Validate: Ensures date is in valid YYYY-MM-DD format + * @audit PERFORMANCE: Uses optimized RPC function 'get_detailed_availability' for complex availability calculation + * @audit AUDIT: High-volume endpoint, consider rate limiting in production */ export async function GET(request: NextRequest) { try { diff --git a/app/api/bookings/[id]/route.ts b/app/api/bookings/[id]/route.ts index 19319c9..d7c1f8f 100644 --- a/app/api/bookings/[id]/route.ts +++ b/app/api/bookings/[id]/route.ts @@ -2,7 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Updates the status of a specific booking + * @description Updates the status of a specific booking by booking ID + * @param {NextRequest} request - HTTP request containing the new status in request body + * @param {Object} params - Route parameters containing the booking ID + * @param {string} params.id - The UUID of the booking to update + * @returns {NextResponse} JSON with success status and updated booking data + * @example PATCH /api/bookings/123e4567-e89b-12d3-a456-426614174000 { "status": "confirmed" } + * @audit BUSINESS RULE: Only allows valid status transitions (pending→confirmed→completed/cancelled/no_show) + * @audit SECURITY: Requires authentication and booking ownership validation + * @audit Validate: Ensures status is one of the predefined valid values + * @audit AUDIT: Status changes are logged in audit_logs table */ export async function PATCH( request: NextRequest, diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts index 1dc4163..ecedbfb 100644 --- a/app/api/bookings/route.ts +++ b/app/api/bookings/route.ts @@ -17,7 +17,8 @@ export async function POST(request: NextRequest) { service_id, location_id, start_time_utc, - notes + notes, + staff_id } = body if (!customer_id && (!customer_email || !customer_first_name || !customer_last_name)) { @@ -81,30 +82,71 @@ export async function POST(request: NextRequest) { const endTimeUtc = endTime.toISOString() - // Check staff availability for the requested time slot - const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', { - p_location_id: location_id, - p_start_time_utc: start_time_utc, - p_end_time_utc: endTimeUtc - }) + let assignedStaffId: string | null = null - if (staffError) { - console.error('Error checking staff availability:', staffError) - return NextResponse.json( - { error: 'Failed to check staff availability' }, - { status: 500 } - ) + if (staff_id) { + const { data: requestedStaff, error: staffError } = await supabaseAdmin + .from('staff') + .select('id, display_name') + .eq('id', staff_id) + .eq('is_active', true) + .single() + + if (staffError || !requestedStaff) { + return NextResponse.json( + { error: 'Staff member not found or inactive' }, + { status: 404 } + ) + } + + const { data: staffAvailability, error: availabilityError } = await supabaseAdmin + .rpc('get_available_staff', { + p_location_id: location_id, + p_start_time_utc: start_time_utc, + p_end_time_utc: endTimeUtc + }) + + if (availabilityError) { + return NextResponse.json( + { error: 'Failed to check staff availability' }, + { status: 500 } + ) + } + + const isStaffAvailable = staffAvailability?.some((s: any) => s.staff_id === staff_id) + if (!isStaffAvailable) { + return NextResponse.json( + { error: 'Selected staff member is not available for the selected time' }, + { status: 409 } + ) + } + + assignedStaffId = staff_id + } else { + const { data: availableStaff, error: staffError } = await supabaseAdmin.rpc('get_available_staff', { + p_location_id: location_id, + p_start_time_utc: start_time_utc, + p_end_time_utc: endTimeUtc + }) + + if (staffError) { + console.error('Error checking staff availability:', staffError) + return NextResponse.json( + { error: 'Failed to check staff availability' }, + { status: 500 } + ) + } + + if (!availableStaff || availableStaff.length === 0) { + return NextResponse.json( + { error: 'No staff available for the selected time' }, + { status: 409 } + ) + } + + assignedStaffId = availableStaff[0].staff_id } - if (!availableStaff || availableStaff.length === 0) { - return NextResponse.json( - { error: 'No staff available for the selected time' }, - { status: 409 } - ) - } - - const assignedStaff = availableStaff[0] - // Check resource availability with service priority const { data: availableResources, error: resourcesError } = await supabaseAdmin.rpc('get_available_resources_with_priority', { p_location_id: location_id, @@ -176,7 +218,7 @@ export async function POST(request: NextRequest) { customer_id: customer.id, service_id, location_id, - staff_id: assignedStaff.staff_id, + staff_id: assignedStaffId, resource_id: assignedResource.resource_id, short_id: shortId, status: 'pending', diff --git a/app/api/create-payment-intent/route.ts b/app/api/create-payment-intent/route.ts index dfa8903..6907385 100644 --- a/app/api/create-payment-intent/route.ts +++ b/app/api/create-payment-intent/route.ts @@ -3,9 +3,16 @@ import Stripe from 'stripe' import { supabaseAdmin } from '@/lib/supabase/admin' /** - * @description Creates a Stripe payment intent for booking deposit (50% of service price, max $200) - * @param {NextRequest} request - Request containing booking details - * @returns {NextResponse} Payment intent client secret and amount + * @description Creates a Stripe payment intent for booking deposit payment + * @param {NextRequest} request - HTTP request containing customer and service details + * @returns {NextResponse} JSON with Stripe client secret, deposit amount, and service name + * @example POST /api/create-payment-intent { customer_email: "...", service_id: "...", location_id: "...", start_time_utc: "..." } + * @audit BUSINESS RULE: Calculates deposit as 50% of service price, capped at $200 maximum + * @audit SECURITY: Requires valid Stripe configuration and service validation + * @audit Validate: Ensures service exists and customer details are provided + * @audit Validate: Validates start_time_utc format and location validity + * @audit AUDIT: Payment intent creation is logged for audit trail + * @audit PERFORMANCE: Single database query to fetch service pricing */ export async function POST(request: NextRequest) { try { diff --git a/app/api/kiosk/bookings/route.ts b/app/api/kiosk/bookings/route.ts index 24dcc1b..2097f2c 100644 --- a/app/api/kiosk/bookings/route.ts +++ b/app/api/kiosk/bookings/route.ts @@ -1,6 +1,14 @@ import { NextRequest, NextResponse } from 'next/server' import { supabaseAdmin } from '@/lib/supabase/admin' +/** + * @description Validates kiosk API key and returns kiosk record if valid + * @param {NextRequest} request - HTTP request containing x-kiosk-api-key header + * @returns {Promise} Kiosk record with id, location_id, is_active or null if invalid + * @example validateKiosk(request) + * @audit SECURITY: Simple API key validation for kiosk operations + * @audit Validate: Checks both api_key match and is_active status + */ async function validateKiosk(request: NextRequest) { const apiKey = request.headers.get('x-kiosk-api-key') @@ -19,7 +27,16 @@ async function validateKiosk(request: NextRequest) { } /** - * @description Retrieves pending/confirmed bookings for kiosk + * @description Retrieves bookings for kiosk display, filtered by optional short_id and date + * @param {NextRequest} request - HTTP request with x-kiosk-api-key header and optional query params: short_id, date + * @returns {NextResponse} JSON with array of pending/confirmed bookings for the kiosk location + * @example GET /api/kiosk/bookings?short_id=ABC123 (Search by booking code) + * @example GET /api/kiosk/bookings?date=2026-01-21 (Get all bookings for date) + * @audit BUSINESS RULE: Returns only pending and confirmed bookings (not cancelled/completed) + * @audit SECURITY: Authenticated via x-kiosk-api-key header; returns only location-specific bookings + * @audit Validate: Filters by kiosk's assigned location automatically + * @audit PERFORMANCE: Indexed queries on location_id, status, and start_time_utc + * @audit AUDIT: Kiosk booking access logged for operational monitoring */ export async function GET(request: NextRequest) { try { diff --git a/app/api/public/availability/route.ts b/app/api/public/availability/route.ts index bdc478c..9269c05 100644 --- a/app/api/public/availability/route.ts +++ b/app/api/public/availability/route.ts @@ -2,7 +2,15 @@ import { NextRequest, NextResponse } from 'next/server' import { supabase } from '@/lib/supabase/client' /** - * @description Public API - Retrieves basic availability information + * @description Public API endpoint providing basic location and service information for booking availability overview + * @param {NextRequest} request - HTTP request with required query parameter: location_id + * @returns {NextResponse} JSON with location details and list of active services, plus guidance to detailed availability endpoint + * @example GET /api/public/availability?location_id=123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Provides high-level availability info; detailed time slots available via /api/availability/time-slots + * @audit SECURITY: Public endpoint; no authentication required; returns only active locations and services + * @audit Validate: Ensures location_id is provided and location is active + * @audit PERFORMANCE: Single query fetches location and services with indexed lookups + * @audit AUDIT: High-volume public endpoint; consider rate limiting in production */ export async function GET(request: NextRequest) { try { diff --git a/app/api/receipts/[bookingId]/route.ts b/app/api/receipts/[bookingId]/route.ts index ab5c9e6..757feb0 100644 --- a/app/api/receipts/[bookingId]/route.ts +++ b/app/api/receipts/[bookingId]/route.ts @@ -4,7 +4,19 @@ import jsPDF from 'jspdf' import { format } from 'date-fns' import { es } from 'date-fns/locale' -/** @description Generate PDF receipt for booking */ +/** + * @description Generates a PDF receipt for a completed booking + * @param {NextRequest} request - HTTP request (no body required for GET) + * @param {Object} params - Route parameters containing booking UUID + * @param {string} params.bookingId - The UUID of the booking to generate receipt for + * @returns {NextResponse} PDF file as binary response with Content-Type application/pdf + * @example GET /api/receipts/123e4567-e89b-12d3-a456-426614174000 + * @audit BUSINESS RULE: Generates receipt with booking details, service info, pricing, and branding + * @audit SECURITY: Validates booking exists and user has access to view receipt + * @audit Validate: Ensures booking data is complete before PDF generation + * @audit PERFORMANCE: Single query fetches all related booking data (customer, service, staff, location) + * @audit AUDIT: Receipt generation is logged for audit trail + */ export async function GET( request: NextRequest, { params }: { params: { bookingId: string } } diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts index 93a6030..3263c22 100644 --- a/app/api/webhooks/stripe/route.ts +++ b/app/api/webhooks/stripe/route.ts @@ -3,9 +3,17 @@ import { supabaseAdmin } from '@/lib/supabase/admin' import Stripe from 'stripe' /** - * @description Handle Stripe webhooks for payment intents and refunds - * @param {NextRequest} request - Raw Stripe webhook payload with signature - * @returns {NextResponse} Webhook processing result + * @description Processes Stripe webhook events for payment lifecycle management + * @param {NextRequest} request - HTTP request with raw Stripe webhook payload and stripe-signature header + * @returns {NextResponse} JSON confirming webhook receipt and processing status + * @example POST /api/webhooks/stripe (Stripe sends webhook payload) + * @audit BUSINESS RULE: Handles payment_intent.succeeded, payment_intent.payment_failed, and charge.refunded events + * @audit SECURITY: Verifies Stripe webhook signature using STRIPE_WEBHOOK_SECRET to prevent spoofing + * @audit Validate: Checks for duplicate event processing using event_id tracking + * @audit Validate: Returns 400 for missing signature or invalid signature + * @audit PERFORMANCE: Uses idempotency check to prevent duplicate processing + * @audit AUDIT: All webhook events logged in webhook_logs table with full payload + * @audit RELIABILITY: Critical for payment reconciliation - must be highly available */ export async function POST(request: NextRequest) { try { diff --git a/app/booking/cita/page.tsx b/app/booking/cita/page.tsx index 473fb61..94667a9 100644 --- a/app/booking/cita/page.tsx +++ b/app/booking/cita/page.tsx @@ -40,9 +40,10 @@ export default function CitaPage() { const date = searchParams.get('date') const time = searchParams.get('time') const customer_id = searchParams.get('customer_id') + const staff_id = searchParams.get('staff_id') if (service_id && location_id && date && time) { - fetchBookingDetails(service_id, location_id, date, time) + fetchBookingDetails(service_id, location_id, date, time, staff_id) } if (customer_id) { @@ -70,7 +71,7 @@ export default function CitaPage() { } } - const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string) => { + const fetchBookingDetails = async (serviceId: string, locationId: string, date: string, time: string, staffId?: string | null) => { try { const response = await fetch(`/api/availability/time-slots?location_id=${locationId}&service_id=${serviceId}&date=${date}`) const data = await response.json() @@ -86,7 +87,8 @@ export default function CitaPage() { location_id: locationId, date: date, time: time, - startTime: `${date}T${time}` + startTime: `${date}T${time}`, + staff_id: staffId || null }) } catch (error) { console.error('Error fetching booking details:', error) @@ -189,6 +191,7 @@ export default function CitaPage() { location_id: bookingDetails.location_id, start_time_utc: bookingDetails.startTime, notes: formData.notas, + staff_id: bookingDetails.staff_id, payment_method_id: 'mock_' + paymentMethod.cardNumber.slice(-4), deposit_amount: depositAmount }) diff --git a/app/booking/servicios/page.tsx b/app/booking/servicios/page.tsx index 075f15c..ed7495e 100644 --- a/app/booking/servicios/page.tsx +++ b/app/booking/servicios/page.tsx @@ -1,5 +1,13 @@ 'use client' +/** + * @description Service selection and appointment booking page for The Boutique + * @audit BUSINESS RULE: Multi-step booking flow: service → datetime → confirm → client registration + * @audit SECURITY: Public endpoint with rate limiting recommended for availability checks + * @audit Validate: All steps must be completed before final booking submission + * @audit PERFORMANCE: Auto-fetches services, locations, and time slots based on selections + */ + import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -23,8 +31,24 @@ interface Location { timezone: string } -type BookingStep = 'service' | 'datetime' | 'confirm' | 'client' +interface Staff { + id: string + display_name: string + role: string +} +type BookingStep = 'service' | 'datetime' | 'artist' | 'confirm' | 'client' + +/** + * @description Booking flow page guiding customers through service selection, date/time, and confirmation + * @returns {JSX.Element} Multi-step booking wizard with service cards, date picker, time slots, and confirmation + * @audit BUSINESS RULE: Time slots filtered by service duration and staff availability + * @audit BUSINESS RULE: Time slots respect location business hours and existing bookings + * @audit SECURITY: Public endpoint; no authentication required for browsing + * @audit Validate: Service, location, date, and time required before proceeding + * @audit PERFORMANCE: Dynamic time slot loading based on service and date selection + * @audit AUDIT: Booking attempts logged for analytics and capacity planning + */ export default function ServiciosPage() { const [services, setServices] = useState([]) const [locations, setLocations] = useState([]) @@ -33,6 +57,8 @@ export default function ServiciosPage() { const [selectedDate, setSelectedDate] = useState(new Date()) const [timeSlots, setTimeSlots] = useState([]) const [selectedTime, setSelectedTime] = useState('') + const [availableArtists, setAvailableArtists] = useState([]) + const [selectedArtist, setSelectedArtist] = useState('') const [currentStep, setCurrentStep] = useState('service') const [loading, setLoading] = useState(false) const [errors, setErrors] = useState>({}) @@ -90,6 +116,14 @@ export default function ServiciosPage() { if (data.availability) { setTimeSlots(data.availability) } + + const artistsResponse = await fetch( + `/api/availability/staff?location_id=${selectedLocation}&service_id=${selectedService}&date=${formattedDate}` + ) + const artistsData = await artistsResponse.json() + if (artistsData.staff) { + setAvailableArtists(artistsData.staff) + } } catch (error) { console.error('Error fetching time slots:', error) setErrors({ ...errors, timeSlots: 'Error al cargar horarios' }) @@ -111,6 +145,10 @@ export default function ServiciosPage() { return selectedService && selectedLocation && selectedDate && selectedTime } + const canProceedToArtist = () => { + return selectedService && selectedLocation && selectedDate && selectedTime + } + const handleProceed = () => { setErrors({}) @@ -133,13 +171,33 @@ export default function ServiciosPage() { setErrors({ time: 'Selecciona un horario' }) return } - setCurrentStep('confirm') + if (availableArtists.length > 0) { + setCurrentStep('artist') + } else { + const params = new URLSearchParams({ + service_id: selectedService, + location_id: selectedLocation, + date: format(selectedDate!, 'yyyy-MM-dd'), + time: selectedTime + }) + window.location.href = `/booking/cita?${params.toString()}` + } + } else if (currentStep === 'artist') { + const params = new URLSearchParams({ + service_id: selectedService, + location_id: selectedLocation, + date: format(selectedDate!, 'yyyy-MM-dd'), + time: selectedTime, + staff_id: selectedArtist + }) + window.location.href = `/booking/cita?${params.toString()}` } else if (currentStep === 'confirm') { const params = new URLSearchParams({ service_id: selectedService, location_id: selectedLocation, date: format(selectedDate!, 'yyyy-MM-dd'), - time: selectedTime + time: selectedTime, + staff_id: selectedArtist }) window.location.href = `/booking/cita?${params.toString()}` } @@ -148,8 +206,10 @@ export default function ServiciosPage() { const handleStepBack = () => { if (currentStep === 'datetime') { setCurrentStep('service') - } else if (currentStep === 'confirm') { + } else if (currentStep === 'artist') { setCurrentStep('datetime') + } else if (currentStep === 'confirm') { + setCurrentStep('artist') } } @@ -267,7 +327,9 @@ export default function ServiciosPage() { ) : (
{timeSlots.map((slot, index) => { - const slotTime = new Date(slot.start_time) + const slotTimeUTC = new Date(slot.start_time) + // JavaScript automatically converts ISO string to local timezone + // Since Monterrey is UTC-6, this gives us the correct local time return ( ) })} @@ -296,6 +358,66 @@ export default function ServiciosPage() { )} + {currentStep === 'artist' && ( + <> + + + + + Seleccionar Artista + + + {availableArtists.length > 0 + ? 'Elige el artista que prefieres para tu servicio' + : 'Se asignará automáticamente el primer artista disponible'} + + + + {availableArtists.length === 0 ? ( +
+ No hay artistas específicos disponibles. Se asignará automáticamente. +
+ ) : ( +
+ {availableArtists.map((artist) => ( +
setSelectedArtist(artist.id)} + > +
+
+ {artist.display_name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} +
+
+

+ {artist.display_name} +

+

+ {artist.role} +

+
+
+
+ ))} +
+ )} +
+
+ + )} + {currentStep === 'confirm' && selectedServiceData && selectedLocationData && selectedDate && selectedTime && ( <> @@ -314,10 +436,16 @@ export default function ServiciosPage() {

Fecha

{format(selectedDate, 'PPP', { locale: es })}

-
-

Hora

-

{format(parseISO(selectedTime), 'HH:mm', { locale: es })}

-
+
+

Hora

+

{format(new Date(selectedTime), 'HH:mm', { locale: es })}

+
+ {selectedArtist && ( +
+

Artista

+

{availableArtists.find(a => a.id === selectedArtist)?.display_name || 'Seleccionado'}

+
+ )}

Duración

{selectedServiceData.duration_minutes} minutos

diff --git a/app/kiosk/[locationId]/page.tsx b/app/kiosk/[locationId]/page.tsx index aa0b80d..37b10ed 100644 --- a/app/kiosk/[locationId]/page.tsx +++ b/app/kiosk/[locationId]/page.tsx @@ -7,7 +7,19 @@ import { BookingConfirmation } from '@/components/kiosk/BookingConfirmation' import { WalkInFlow } from '@/components/kiosk/WalkInFlow' import { Calendar, UserPlus, MapPin, Clock } from 'lucide-react' -/** @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation. */ +/** + * @description Kiosk interface component for location-based check-in confirmations and walk-in booking creation + * @param {Object} params - Route parameters containing the locationId + * @param {string} params.locationId - The UUID of the salon location this kiosk serves + * @returns {JSX.Element} Interactive kiosk interface with authentication, clock, and action cards + * @audit BUSINESS RULE: Kiosk enables customer self-service for check-in and walk-in bookings + * @audit BUSINESS RULE: Real-time clock displays in location's timezone for customer reference + * @audit SECURITY: Device authentication via API key required before any operations + * @audit SECURITY: Kiosk mode has no user authentication - relies on device-level security + * @audit Validate: Location must be active and have associated kiosk device registered + * @audit PERFORMANCE: Single-page app with view-based rendering (no page reloads) + * @audit AUDIT: Kiosk operations logged for security and operational monitoring + */ export default function KioskPage({ params }: { params: { locationId: string } }) { const [apiKey, setApiKey] = useState(null) const [location, setLocation] = useState(null) diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx index 6801952..b3aed89 100644 --- a/components/auth-guard.tsx +++ b/components/auth-guard.tsx @@ -5,8 +5,15 @@ import { useRouter, usePathname } from 'next/navigation' import { useAuth } from '@/lib/auth/context' /** - * AuthGuard component that shows loading state while authentication is being determined - * Redirect logic is now handled by AuthProvider to avoid conflicts + * @description Authentication guard component that protects routes requiring login + * @param {Object} props - Component props + * @param {React.ReactNode} props.children - Child components to render when authenticated + * @returns {JSX.Element} Loading state while auth is determined, or children when authenticated + * @audit BUSINESS RULE: AuthGuard is a client-side guard for protected routes + * @audit SECURITY: Prevents rendering protected content until authentication verified + * @audit Validate: Loading state shown while auth provider determines user session + * @audit PERFORMANCE: No API calls - relies on AuthProvider's cached session state + * @audit Note: Actual redirect logic handled by AuthProvider to avoid conflicts */ export function AuthGuard({ children }: { children: React.ReactNode }) { const { loading: authLoading } = useAuth() diff --git a/components/booking/date-picker.tsx b/components/booking/date-picker.tsx index cb0bb8e..a78a0db 100644 --- a/components/booking/date-picker.tsx +++ b/components/booking/date-picker.tsx @@ -10,6 +10,21 @@ interface DatePickerProps { disabled?: boolean } +/** + * @description Custom date picker component for booking flow with month navigation and date selection + * @param {DatePickerProps} props - Component props including selected date, selection callback, and constraints + * @param {Date | null} props.selectedDate - Currently selected date value + * @param {(date: Date) => void} props.onDateSelect - Callback invoked when user selects a date + * @param {Date} props.minDate - Optional minimum selectable date (defaults to today if not provided) + * @param {boolean} props.disabled - Optional flag to disable all interactions + * @returns {JSX.Element} Interactive calendar grid with month navigation and date selection + * @audit BUSINESS RULE: Calendar starts on Monday (Spanish locale convention) + * @audit BUSINESS RULE: Disabled dates cannot be selected (past dates via minDate) + * @audit SECURITY: Client-side only component with no external data access + * @audit Validate: minDate is enforced via date comparison before selection + * @audit PERFORMANCE: Uses date-fns for efficient date calculations + * @audit UI: Today's date indicated with visual marker (dot indicator) + */ export default function DatePicker({ selectedDate, onDateSelect, minDate, disabled }: DatePickerProps) { const [currentMonth, setCurrentMonth] = useState(selectedDate ? new Date(selectedDate) : new Date()) diff --git a/components/calendar-view.tsx b/components/calendar-view.tsx index 1589605..8186cfe 100644 --- a/components/calendar-view.tsx +++ b/components/calendar-view.tsx @@ -1,5 +1,5 @@ /** - * @description Calendar view component with drag-and-drop rescheduling functionality + * @description Calendar view component with drag-and-drop rescheduling and booking creation * @audit BUSINESS RULE: Calendar shows only bookings for selected date and filters * @audit SECURITY: Component requires authenticated admin/manager user context * @audit PERFORMANCE: Auto-refresh every 30 seconds for real-time updates @@ -16,7 +16,10 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' -import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Calendar, ChevronLeft, ChevronRight, Clock, User, MapPin, Plus } from 'lucide-react' import { DndContext, closestCenter, @@ -36,6 +39,7 @@ import { useSortable, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' +import { checkStaffCanPerformService, checkForConflicts, rescheduleBooking } from '@/lib/calendar-utils' interface Booking { id: string @@ -68,6 +72,7 @@ interface Staff { id: string display_name: string role: string + location_id: string } interface Location { @@ -163,9 +168,10 @@ interface TimeSlotProps { bookings: Booking[] staffId: string onBookingDrop?: (bookingId: string, newTime: string, staffId: string) => void + onSlotClick?: (time: Date, staffId: string) => void } -function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) { +function TimeSlot({ time, bookings, staffId, onBookingDrop, onSlotClick }: TimeSlotProps) { const timeBookings = bookings.filter(booking => booking.staff.id === staffId && parseISO(booking.startTime).getHours() === time.getHours() && @@ -173,7 +179,15 @@ function TimeSlot({ time, bookings, staffId, onBookingDrop }: TimeSlotProps) { ) return ( -
+
onSlotClick && timeBookings.length === 0 && onSlotClick(time, staffId)} + > + {timeBookings.length === 0 && onSlotClick && ( +
+ +
+ )} {timeBookings.map(booking => ( void + onSlotClick?: (time: Date, staffId: string) => void } -function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: StaffColumnProps) { +function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop, onSlotClick }: StaffColumnProps) { const staffBookings = bookings.filter(booking => booking.staff.id === staff.id) - // Check for conflicts (overlapping bookings) - const conflicts = [] - for (let i = 0; i < staffBookings.length; i++) { - for (let j = i + 1; j < staffBookings.length; j++) { - const booking1 = staffBookings[i] - const booking2 = staffBookings[j] - - const start1 = parseISO(booking1.startTime) - const end1 = parseISO(booking1.endTime) - const start2 = parseISO(booking2.startTime) - const end2 = parseISO(booking2.endTime) - - // Check if bookings overlap - if (start1 < end2 && start2 < end1) { - conflicts.push({ - booking1: booking1.id, - booking2: booking2.id, - time: Math.min(start1.getTime(), start2.getTime()) - }) - } - } - } - const timeSlots = [] const [startHour, startMinute] = businessHours.start.split(':').map(Number) @@ -231,7 +223,7 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St while (currentTime < endTime) { timeSlots.push(new Date(currentTime)) - currentTime = addMinutes(currentTime, 15) // 15-minute slots + currentTime = addMinutes(currentTime, 15) } return ( @@ -247,15 +239,6 @@ function StaffColumn({ staff, date, bookings, businessHours, onBookingDrop }: St
- {/* Conflict indicator */} - {conflicts.length > 0 && ( -
-
- ⚠️ {conflicts.length} conflicto{conflicts.length > 1 ? 's' : ''} -
-
- )} - {timeSlots.map((timeSlot, index) => (
))} @@ -288,6 +272,121 @@ export default function CalendarView() { const [rescheduleError, setRescheduleError] = useState(null) const [lastUpdated, setLastUpdated] = useState(null) + const [showCreateBooking, setShowCreateBooking] = useState(false) + const [createBookingData, setCreateBookingData] = useState<{ + time: Date | null + staffId: string | null + customerId: string + serviceId: string + locationId: string + notes: string + }>({ + time: null, + staffId: null, + customerId: '', + serviceId: '', + locationId: '', + notes: '' + }) + const [createBookingError, setCreateBookingError] = useState(null) + const [services, setServices] = useState([]) + const [customers, setCustomers] = useState([]) + + const fetchServices = async () => { + try { + const response = await fetch('/api/services') + const data = await response.json() + if (data.success) { + setServices(data.services || []) + } + } catch (error) { + console.error('Error fetching services:', error) + } + } + + const fetchCustomers = async () => { + try { + const response = await fetch('/api/customers') + const data = await response.json() + if (data.success) { + setCustomers(data.customers || []) + } + } catch (error) { + console.error('Error fetching customers:', error) + } + } + + useEffect(() => { + fetchServices() + fetchCustomers() + }, []) + + const handleSlotClick = (time: Date, staffId: string) => { + const locationId = selectedLocations.length > 0 ? selectedLocations[0] : (calendarData?.locations[0]?.id || '') + setCreateBookingData({ + time, + staffId, + customerId: '', + serviceId: '', + locationId, + notes: '' + }) + setShowCreateBooking(true) + setCreateBookingError(null) + } + + const handleCreateBooking = async (e: React.FormEvent) => { + e.preventDefault() + setCreateBookingError(null) + + if (!createBookingData.time || !createBookingData.staffId || !createBookingData.customerId || !createBookingData.serviceId || !createBookingData.locationId) { + setCreateBookingError('Todos los campos son obligatorios') + return + } + + try { + setLoading(true) + const startTimeUtc = createBookingData.time.toISOString() + + const response = await fetch('/api/bookings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + customer_id: createBookingData.customerId, + service_id: createBookingData.serviceId, + location_id: createBookingData.locationId, + start_time_utc: startTimeUtc, + staff_id: createBookingData.staffId, + notes: createBookingData.notes || null + }), + }) + + const result = await response.json() + + if (result.success) { + setShowCreateBooking(false) + setCreateBookingData({ + time: null, + staffId: null, + customerId: '', + serviceId: '', + locationId: '', + notes: '' + }) + await fetchCalendarData() + } else { + setCreateBookingError(result.error || 'Error al crear la cita') + } + } catch (error) { + console.error('Error creating booking:', error) + setCreateBookingError('Error de conexión al crear la cita') + } finally { + setLoading(false) + } + } + const fetchCalendarData = useCallback(async () => { setLoading(true) try { @@ -325,11 +424,10 @@ export default function CalendarView() { fetchCalendarData() }, [fetchCalendarData]) - // Auto-refresh every 30 seconds for real-time updates useEffect(() => { const interval = setInterval(() => { fetchCalendarData() - }, 30000) // 30 seconds + }, 30000) return () => clearInterval(interval) }, [fetchCalendarData]) @@ -353,34 +451,22 @@ export default function CalendarView() { setCurrentDate(new Date()) } - const handleStaffFilter = (staffIds: string[]) => { - setSelectedStaff(staffIds) - } - const handleDragEnd = async (event: DragEndEvent) => { const { active, over } = event if (!over) return const bookingId = active.id as string - const targetStaffId = over.id as string + const targetInfo = over.id as string - // Find the booking - const booking = calendarData?.bookings.find(b => b.id === bookingId) - if (!booking) return - - // For now, we'll implement a simple time slot change - // In a real implementation, you'd need to calculate the exact time from drop position - // For demo purposes, we'll move to the next available slot + const [targetStaffId, targetTime] = targetInfo.includes('-') ? targetInfo.split('-') : [targetInfo, null] try { setRescheduleError(null) - // Calculate new start time (for demo, move to next hour) - const currentStart = parseISO(booking.startTime) - const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000)) // +1 hour + const currentStart = parseISO(bookingId) + const newStartTime = new Date(currentStart.getTime() + (60 * 60 * 1000)) - // Call the reschedule API const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, { method: 'POST', headers: { @@ -389,14 +475,13 @@ export default function CalendarView() { body: JSON.stringify({ bookingId, newStartTime: newStartTime.toISOString(), - newStaffId: targetStaffId !== booking.staff.id ? targetStaffId : undefined, + newStaffId: targetStaffId, }), }) const result = await response.json() if (result.success) { - // Refresh calendar data await fetchCalendarData() setRescheduleError(null) } else { @@ -423,7 +508,136 @@ export default function CalendarView() { return (
- {/* Header Controls */} + + + + Crear Nueva Cita + + {createBookingData.time && ( + + {format(createBookingData.time, 'EEEE, d MMMM yyyy HH:mm', { locale: es })} + + )} + + + +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + setCreateBookingData({ ...createBookingData, notes: e.target.value })} + placeholder="Notas adicionales (opcional)" + /> +
+ + {createBookingError && ( +
+

{createBookingError}

+
+ )} + + + + + +
+
+
+
@@ -459,11 +673,7 @@ export default function CalendarView() { { - if (value === 'all') { - setSelectedStaff([]) - } else { - setSelectedStaff([value]) - } + value === 'all' ? setSelectedStaff([]) : setSelectedStaff([value]) }} > @@ -515,7 +721,6 @@ export default function CalendarView() { - {/* Calendar Grid */}
- {/* Time Column */}
Hora @@ -533,7 +737,7 @@ export default function CalendarView() { const timeSlots = [] const [startHour] = calendarData.businessHours.start.split(':').map(Number) const [endHour] = calendarData.businessHours.end.split(':').map(Number) - + for (let hour = startHour; hour <= endHour; hour++) { timeSlots.push(
@@ -546,7 +750,6 @@ export default function CalendarView() { })()}
- {/* Staff Columns */}
{calendarData.staff.map(staff => ( ))}
@@ -564,4 +768,4 @@ export default function CalendarView() {
) -} \ No newline at end of file +} diff --git a/components/kiosk/BookingConfirmation.tsx b/components/kiosk/BookingConfirmation.tsx index f273a93..2b42fb5 100644 --- a/components/kiosk/BookingConfirmation.tsx +++ b/components/kiosk/BookingConfirmation.tsx @@ -1,5 +1,13 @@ 'use client' +/** + * @description Kiosk booking confirmation interface for customers arriving with appointments + * @audit BUSINESS RULE: Customers confirm appointments by entering 6-character short ID + * @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls + * @audit Validate: Only pending bookings can be confirmed; already confirmed shows warning + * @audit PERFORMANCE: Large touch-friendly input optimized for self-service kiosks + */ + import { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -12,7 +20,17 @@ interface BookingConfirmationProps { } /** - * BookingConfirmation component that allows confirming a booking by short ID. + * @description Booking confirmation component for kiosk self-service check-in + * @param {string} apiKey - Kiosk API key for authentication + * @param {Function} onConfirm - Callback when booking is successfully confirmed + * @param {Function} onCancel - Callback when customer cancels the process + * @returns {JSX.Element} Input form for 6-character booking code with confirmation options + * @audit BUSINESS RULE: Search by short_id (6 characters) for quick customer lookup + * @audit BUSINESS RULE: Only pending bookings can be confirmed; other statuses show error + * @audit SECURITY: All API calls require valid kiosk API key in header + * @audit Validate: Short ID must be exactly 6 characters + * @audit PERFORMANCE: Single API call to fetch booking by short_id + * @audit AUDIT: Booking confirmations logged through /api/kiosk/bookings/[shortId]/confirm */ export function BookingConfirmation({ apiKey, onConfirm, onCancel }: BookingConfirmationProps) { const [shortId, setShortId] = useState('') diff --git a/components/kiosk/WalkInFlow.tsx b/components/kiosk/WalkInFlow.tsx index cb2f0a0..b5cfa0e 100644 --- a/components/kiosk/WalkInFlow.tsx +++ b/components/kiosk/WalkInFlow.tsx @@ -1,5 +1,13 @@ 'use client' +/** + * @description Kiosk walk-in booking flow for in-store service reservations + * @audit BUSINESS RULE: Walk-in flow designed for touch screen with large buttons and simple navigation + * @audit SECURITY: Authenticated via x-kiosk-api-key header for all API calls + * @audit Validate: Multi-step flow with service → customer → confirm → success states + * @audit PERFORMANCE: Optimized for offline-capable touch interface + */ + import { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -14,7 +22,17 @@ interface WalkInFlowProps { } /** - * WalkInFlow component that manages the walk-in booking process in steps. + * @description Walk-in booking flow component for kiosk terminals + * @param {string} apiKey - Kiosk API key for authentication + * @param {Function} onComplete - Callback when walk-in booking is completed successfully + * @param {Function} onCancel - Callback when customer cancels the walk-in process + * @returns {JSX.Element} Multi-step wizard for service selection, customer info, and confirmation + * @audit BUSINESS RULE: 4-step flow: services → customer info → resource assignment → success + * @audit BUSINESS RULE: Resources auto-assigned based on availability and service priority + * @audit SECURITY: All API calls require valid kiosk API key in header + * @audit Validate: Customer name and service selection required before booking + * @audit PERFORMANCE: Single-page flow optimized for touch interaction + * @audit AUDIT: Walk-in bookings logged through /api/kiosk/walkin endpoint */ export function WalkInFlow({ apiKey, onComplete, onCancel }: WalkInFlowProps) { const [step, setStep] = useState<'services' | 'customer' | 'confirm' | 'success'>('services') diff --git a/components/kiosks-management.tsx b/components/kiosks-management.tsx new file mode 100644 index 0000000..378d8dc --- /dev/null +++ b/components/kiosks-management.tsx @@ -0,0 +1,388 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Plus, Edit, Trash2, Smartphone, MapPin, Key, Wifi } from 'lucide-react' + +interface Kiosk { + id: string + device_name: string + display_name: string + api_key: string + ip_address?: string + is_active: boolean + created_at: string + location?: { + id: string + name: string + address: string + } +} + +interface Location { + id: string + name: string + address: string +} + +export default function KiosksManagement() { + const [kiosks, setKiosks] = useState([]) + const [locations, setLocations] = useState([]) + const [loading, setLoading] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingKiosk, setEditingKiosk] = useState(null) + const [showApiKey, setShowApiKey] = useState(null) + const [formData, setFormData] = useState({ + device_name: '', + display_name: '', + location_id: '', + ip_address: '' + }) + + useEffect(() => { + fetchKiosks() + fetchLocations() + }, []) + + const fetchKiosks = async () => { + setLoading(true) + try { + const response = await fetch('/api/aperture/kiosks') + const data = await response.json() + if (data.success) { + setKiosks(data.kiosks) + } + } catch (error) { + console.error('Error fetching kiosks:', error) + } finally { + setLoading(false) + } + } + + const fetchLocations = async () => { + try { + const response = await fetch('/api/aperture/locations') + const data = await response.json() + if (data.success) { + setLocations(data.locations || []) + } + } catch (error) { + console.error('Error fetching locations:', error) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + const url = editingKiosk + ? `/api/aperture/kiosks/${editingKiosk.id}` + : '/api/aperture/kiosks' + + const method = editingKiosk ? 'PUT' : 'POST' + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }) + + const data = await response.json() + + if (data.success) { + await fetchKiosks() + setDialogOpen(false) + setEditingKiosk(null) + setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' }) + } else { + alert(data.error || 'Error saving kiosk') + } + } catch (error) { + console.error('Error saving kiosk:', error) + alert('Error saving kiosk') + } + } + + const handleEdit = (kiosk: Kiosk) => { + setEditingKiosk(kiosk) + setFormData({ + device_name: kiosk.device_name, + display_name: kiosk.display_name, + location_id: kiosk.location?.id || '', + ip_address: kiosk.ip_address || '' + }) + setDialogOpen(true) + } + + const handleDelete = async (kiosk: Kiosk) => { + if (!confirm(`¿Estás seguro de que quieres eliminar el kiosko "${kiosk.device_name}"?`)) { + return + } + + try { + const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, { + method: 'DELETE' + }) + + const data = await response.json() + + if (data.success) { + await fetchKiosks() + } else { + alert(data.error || 'Error deleting kiosk') + } + } catch (error) { + console.error('Error deleting kiosk:', error) + alert('Error deleting kiosk') + } + } + + const toggleKioskStatus = async (kiosk: Kiosk) => { + try { + const response = await fetch(`/api/aperture/kiosks/${kiosk.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...kiosk, + is_active: !kiosk.is_active + }) + }) + + const data = await response.json() + + if (data.success) { + await fetchKiosks() + } else { + alert(data.error || 'Error updating kiosk status') + } + } catch (error) { + console.error('Error toggling kiosk status:', error) + } + } + + const copyApiKey = (apiKey: string) => { + navigator.clipboard.writeText(apiKey) + setShowApiKey(apiKey) + setTimeout(() => setShowApiKey(null), 2000) + } + + const openCreateDialog = () => { + setEditingKiosk(null) + setFormData({ device_name: '', display_name: '', location_id: '', ip_address: '' }) + setDialogOpen(true) + } + + return ( +
+
+
+

Gestión de Kioskos

+

Administra los dispositivos kiosko para check-in

+
+ +
+ + + + + + Dispositivos Kiosko + + + {kiosks.length} dispositivos registrados + + + + {loading ? ( +
Cargando kioskos...
+ ) : kiosks.length === 0 ? ( +
+ No hay kioskos registrados. Agrega uno para comenzar. +
+ ) : ( + + + + Dispositivo + Ubicación + IP + API Key + Estado + Acciones + + + + {kiosks.map((kiosk) => ( + + +
+
+ +
+
+
{kiosk.device_name}
+ {kiosk.display_name !== kiosk.device_name && ( +
{kiosk.display_name}
+ )} +
+
+
+ +
+ + {kiosk.location?.name || 'Sin ubicación'} +
+
+ + {kiosk.ip_address ? ( +
+ + {kiosk.ip_address} +
+ ) : ( + Sin IP + )} +
+ + + + + + {kiosk.is_active ? 'Activo' : 'Inactivo'} + + + +
+ + + +
+
+
+ ))} +
+
+ )} +
+
+ + + + + + {editingKiosk ? 'Editar Kiosko' : 'Nuevo Kiosko'} + + + {editingKiosk ? 'Modifica la información del kiosko' : 'Agrega un nuevo dispositivo kiosko'} + + +
+
+
+ + setFormData({...formData, device_name: e.target.value})} + className="col-span-3" + placeholder="Ej. Kiosko Principal" + required + /> +
+
+ + setFormData({...formData, display_name: e.target.value})} + className="col-span-3" + placeholder="Nombre a mostrar" + /> +
+
+ + +
+
+ + setFormData({...formData, ip_address: e.target.value})} + className="col-span-3" + placeholder="192.168.1.100" + /> +
+
+ + + +
+
+
+
+ ) +} diff --git a/components/loading-screen.tsx b/components/loading-screen.tsx index 3bdc5b0..e0055d0 100644 --- a/components/loading-screen.tsx +++ b/components/loading-screen.tsx @@ -2,7 +2,17 @@ import React, { useState, useEffect } from 'react' -/** @description Elegant loading screen with Anchor 23 branding */ +/** + * @description Elegant branded loading screen with Anchor:23 logo reveal animation + * @param {Object} props - Component props + * @param {() => void} props.onComplete - Callback invoked when loading animation completes + * @returns {JSX.Element} Full-screen loading overlay with animated logo and progress bar + * @audit BUSINESS RULE: Loading screen provides brand consistency during app initialization + * @audit SECURITY: Client-side only animation with no external data access + * @audit Validate: onComplete callback triggers app state transition to loaded + * @audit PERFORMANCE: Uses CSS animations for smooth GPU-accelerated transitions + * @audit UI: Features SVG logo with clip-path reveal animation and gradient progress bar + */ export function LoadingScreen({ onComplete }: { onComplete: () => void }) { const [progress, setProgress] = useState(0) const [showLogo, setShowLogo] = useState(false) diff --git a/components/payroll-management.tsx b/components/payroll-management.tsx index 3eb9648..4912951 100644 --- a/components/payroll-management.tsx +++ b/components/payroll-management.tsx @@ -1,5 +1,13 @@ 'use client' +/** + * @description Payroll management interface for calculating and tracking staff compensation + * @audit BUSINESS RULE: Payroll includes base salary, service commissions (10%), and tips (5%) + * @audit SECURITY: Requires authenticated admin/manager role via useAuth hook + * @audit Validate: Payroll period must have valid start and end dates + * @audit AUDIT: Payroll calculations logged through /api/aperture/payroll endpoint + */ + import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -42,6 +50,16 @@ interface PayrollCalculation { hours_worked: number } +/** + * @description Payroll management component with calculation, listing, and reporting features + * @returns {JSX.Element} Complete payroll interface with period selection, staff filtering, and calculation modal + * @audit BUSINESS RULE: Calculates payroll from completed bookings within the selected period + * @audit BUSINESS RULE: Commission is 10% of service revenue, tips are 5% of service revenue + * @audit SECURITY: Requires authenticated admin/manager role; staff cannot access payroll + * @audit Validate: Ensures period dates are valid before calculation + * @audit PERFORMANCE: Auto-sets default period to current month on mount + * @audit AUDIT: Payroll records stored and retrievable for financial reporting + */ export default function PayrollManagement() { const { user } = useAuth() const [payrollRecords, setPayrollRecords] = useState([]) diff --git a/components/pos-system.tsx b/components/pos-system.tsx index fd5ceb9..ac9a6cb 100644 --- a/components/pos-system.tsx +++ b/components/pos-system.tsx @@ -1,5 +1,14 @@ 'use client' +/** + * @description Point of Sale (POS) interface for processing service and product sales with multiple payment methods + * @audit BUSINESS RULE: POS handles service/product sales with cash, card, transfer, giftcard, and membership payments + * @audit SECURITY: Requires authenticated staff member (cashier) via useAuth hook + * @audit Validate: Payment amounts must match cart total before processing + * @audit AUDIT: All sales transactions logged through /api/aperture/pos endpoint + * @audit PERFORMANCE: Optimized for touch interface with large touch targets + */ + import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' @@ -39,6 +48,17 @@ interface SaleResult { receipt: any } +/** + * @description Point of Sale component with cart management, customer selection, and multi-payment support + * @returns {JSX.Element} Complete POS interface with service/product catalog, cart, and payment processing + * @audit BUSINESS RULE: Cart items can be services or products with quantity management + * @audit BUSINESS RULE: Multiple partial payments supported (split payments) + * @audit SECURITY: Requires authenticated staff member; validates user permissions + * @audit Validate: Cart cannot be empty when processing payment + * @audit Validate: Payment total must equal or exceed cart subtotal + * @audit PERFORMANCE: Auto-fetches services, products, and customers on mount + * @audit AUDIT: Sales processed through /api/aperture/pos with full transaction logging + */ export default function POSSystem() { const { user } = useAuth() const [cart, setCart] = useState([]) diff --git a/components/schedule-management.tsx b/components/schedule-management.tsx new file mode 100644 index 0000000..ffd7356 --- /dev/null +++ b/components/schedule-management.tsx @@ -0,0 +1,447 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Plus, Edit, Trash2, Clock, Coffee, Calendar } from 'lucide-react' + +interface StaffSchedule { + id: string + staff_id: string + date: string + start_time: string + end_time: string + is_available: boolean + reason?: string +} + +interface Staff { + id: string + display_name: string + role: string +} + +const DAYS_OF_WEEK = [ + { key: 'monday', label: 'Lunes' }, + { key: 'tuesday', label: 'Martes' }, + { key: 'wednesday', label: 'Miércoles' }, + { key: 'thursday', label: 'Jueves' }, + { key: 'friday', label: 'Viernes' }, + { key: 'saturday', label: 'Sábado' }, + { key: 'sunday', label: 'Domingo' } +] + +const TIME_SLOTS = Array.from({ length: 24 * 2 }, (_, i) => { + const hour = Math.floor(i / 2) + const minute = (i % 2) * 30 + return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` +}) + +export default function ScheduleManagement() { + const [staff, setStaff] = useState([]) + const [selectedStaff, setSelectedStaff] = useState('') + const [schedule, setSchedule] = useState([]) + const [loading, setLoading] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [editingSchedule, setEditingSchedule] = useState(null) + const [formData, setFormData] = useState({ + date: '', + start_time: '09:00', + end_time: '17:00', + is_available: true, + reason: '' + }) + + useEffect(() => { + fetchStaff() + }, []) + + useEffect(() => { + if (selectedStaff) { + fetchSchedule() + } + }, [selectedStaff]) + + const fetchStaff = async () => { + try { + const response = await fetch('/api/aperture/staff') + const data = await response.json() + if (data.success) { + setStaff(data.staff) + } + } catch (error) { + console.error('Error fetching staff:', error) + } + } + + const fetchSchedule = async () => { + if (!selectedStaff) return + + setLoading(true) + try { + const today = new Date() + const startDate = today.toISOString().split('T')[0] + const endDate = new Date(today.setDate(today.getDate() + 30)).toISOString().split('T')[0] + + const response = await fetch( + `/api/aperture/staff/schedule?staff_id=${selectedStaff}&start_date=${startDate}&end_date=${endDate}` + ) + const data = await response.json() + + if (data.success) { + setSchedule(data.availability || []) + } + } catch (error) { + console.error('Error fetching schedule:', error) + } finally { + setLoading(false) + } + } + + const generateWeeklySchedule = async () => { + if (!selectedStaff) return + + const weeklyData = DAYS_OF_WEEK.map((day, index) => { + const date = new Date() + date.setDate(date.getDate() + ((index + 7 - date.getDay()) % 7)) + const dateStr = date.toISOString().split('T')[0] + + const isWeekend = day.key === 'saturday' || day.key === 'sunday' + const startTime = isWeekend ? '10:00' : '09:00' + const endTime = isWeekend ? '15:00' : '17:00' + + return { + staff_id: selectedStaff, + date: dateStr, + start_time: startTime, + end_time: endTime, + is_available: !isWeekend, + reason: isWeekend ? 'Fin de semana' : undefined + } + }) + + try { + for (const day of weeklyData) { + await fetch('/api/aperture/staff/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(day) + }) + } + await fetchSchedule() + alert('Horario semanal generado exitosamente') + } catch (error) { + console.error('Error generating weekly schedule:', error) + alert('Error al generar el horario') + } + } + + const addBreakToSchedule = async (scheduleId: string, breakStart: string, breakEnd: string) => { + try { + await fetch('/api/aperture/staff/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + staff_id: selectedStaff, + date: schedule.find(s => s.id === scheduleId)?.date, + start_time: breakStart, + end_time: breakEnd, + is_available: false, + reason: 'Break de 30 min' + }) + }) + await fetchSchedule() + } catch (error) { + console.error('Error adding break:', error) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + await fetch('/api/aperture/staff/schedule', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + staff_id: selectedStaff, + ...formData + }) + }) + + await fetchSchedule() + setDialogOpen(false) + setEditingSchedule(null) + setFormData({ date: '', start_time: '09:00', end_time: '17:00', is_available: true, reason: '' }) + } catch (error) { + console.error('Error saving schedule:', error) + alert('Error al guardar el horario') + } + } + + const handleDelete = async (scheduleId: string) => { + if (!confirm('¿Eliminar este horario?')) return + + try { + await fetch(`/api/aperture/staff/schedule?id=${scheduleId}`, { + method: 'DELETE' + }) + await fetchSchedule() + } catch (error) { + console.error('Error deleting schedule:', error) + } + } + + const calculateWorkingHours = (schedules: StaffSchedule[]) => { + return schedules.reduce((total, s) => { + if (!s.is_available) return total + const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1]) + const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1]) + return total + (end - start) + }, 0) + } + + const getScheduleForDate = (date: string) => { + return schedule.filter(s => s.date === date && s.is_available) + } + + const getBreaksForDate = (date: string) => { + return schedule.filter(s => s.date === date && !s.is_available && s.reason === 'Break de 30 min') + } + + const selectedStaffData = staff.find(s => s.id === selectedStaff) + + return ( +
+
+
+

Gestión de Horarios

+

Administra horarios y breaks del staff

+
+
+ {selectedStaff && ( + <> + + + + )} +
+
+ + + + Seleccionar Staff + Selecciona un miembro del equipo para ver y gestionar su horario + + + + + + + {selectedStaff && ( + + + + + Horario de {selectedStaffData?.display_name} + + + Total horas programadas: {(calculateWorkingHours(schedule) / 60).toFixed(1)}h + {' • '}Los breaks de 30min se agregan automáticamente cada 8hrs + + + + {loading ? ( +
Cargando horario...
+ ) : ( +
+ {DAYS_OF_WEEK.map((day) => { + const date = new Date() + const currentDayOfWeek = date.getDay() + const targetDayOfWeek = DAYS_OF_WEEK.findIndex(d => d.key === day.key) + const daysUntil = (targetDayOfWeek - currentDayOfWeek + 7) % 7 + date.setDate(date.getDate() + daysUntil) + const dateStr = date.toISOString().split('T')[0] + + const daySchedules = getScheduleForDate(dateStr) + const dayBreaks = getBreaksForDate(dateStr) + + const totalMinutes = daySchedules.reduce((total, s) => { + const start = parseInt(s.start_time.split(':')[0]) * 60 + parseInt(s.start_time.split(':')[1]) + const end = parseInt(s.end_time.split(':')[0]) * 60 + parseInt(s.end_time.split(':')[1]) + return total + (end - start) + }, 0) + + const shouldHaveBreak = totalMinutes >= 480 + + return ( +
+
+
+ {day.label} + {dateStr} +
+
+ {shouldHaveBreak && dayBreaks.length === 0 && ( + + + Break pendiente + + )} + {dayBreaks.length > 0 && ( + + + Break incluido + + )} + 0 ? 'default' : 'secondary'}> + {(totalMinutes / 60).toFixed(1)}h + +
+
+ + {daySchedules.length > 0 ? ( +
+ {daySchedules.map((s) => ( +
+ {s.start_time} - {s.end_time} + +
+ ))} + {dayBreaks.map((b) => ( +
+ {b.start_time} - {b.end_time} (Break) + +
+ ))} +
+ ) : ( +

Sin horario programado

+ )} +
+ ) + })} +
+ )} +
+
+ )} + + + + + Agregar Día de Trabajo + + Define el horario de trabajo para este día + + +
+
+
+ + setFormData({...formData, date: e.target.value})} + className="col-span-3" + required + /> +
+
+ + +
+
+ + +
+
+ + setFormData({...formData, reason: e.target.value})} + className="col-span-3" + placeholder="Opcional" + /> +
+
+ + + +
+
+
+
+ ) +} diff --git a/components/staff-management.tsx b/components/staff-management.tsx index 4a2fa0e..b0aa3ae 100644 --- a/components/staff-management.tsx +++ b/components/staff-management.tsx @@ -18,7 +18,8 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Badge } from '@/components/ui/badge' import { Avatar } from '@/components/ui/avatar' -import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users } from 'lucide-react' +import { Checkbox } from '@/components/ui/checkbox' +import { Plus, Edit, Trash2, Phone, MapPin, Clock, Users, Scissors, X } from 'lucide-react' import { useAuth } from '@/lib/auth/context' interface StaffMember { @@ -39,6 +40,16 @@ interface StaffMember { schedule?: any[] } +interface Service { + id: string + name: string + category: string + duration_minutes: number + base_price: number + isAssigned?: boolean + proficiency?: number +} + interface Location { id: string name: string @@ -60,6 +71,10 @@ export default function StaffManagement() { const [loading, setLoading] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [editingStaff, setEditingStaff] = useState(null) + const [servicesDialogOpen, setServicesDialogOpen] = useState(false) + const [selectedStaffForServices, setSelectedStaffForServices] = useState(null) + const [services, setServices] = useState([]) + const [loadingServices, setLoadingServices] = useState(false) const [formData, setFormData] = useState({ location_id: '', role: '', @@ -72,6 +87,63 @@ export default function StaffManagement() { fetchLocations() }, []) + const fetchServices = async (staffId: string) => { + setLoadingServices(true) + try { + const response = await fetch(`/api/aperture/staff/${staffId}/services`) + const data = await response.json() + if (data.success) { + setServices(data.availableServices || []) + } + } catch (error) { + console.error('Error fetching services:', error) + } finally { + setLoadingServices(false) + } + } + + const openServicesDialog = async (member: StaffMember) => { + setSelectedStaffForServices(member) + await fetchServices(member.id) + setServicesDialogOpen(true) + } + + const toggleServiceAssignment = async (serviceId: string, isCurrentlyAssigned: boolean) => { + if (!selectedStaffForServices) return + + try { + if (isCurrentlyAssigned) { + await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services?service_id=${serviceId}`, { + method: 'DELETE' + }) + } else { + await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service_id: serviceId }) + }) + } + await fetchServices(selectedStaffForServices.id) + } catch (error) { + console.error('Error toggling service:', error) + } + } + + const updateProficiency = async (serviceId: string, level: number) => { + if (!selectedStaffForServices) return + + try { + await fetch(`/api/aperture/staff/${selectedStaffForServices.id}/services`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service_id: serviceId, proficiency_level: level }) + }) + await fetchServices(selectedStaffForServices.id) + } catch (error) { + console.error('Error updating proficiency:', error) + } + } + const fetchStaff = async () => { setLoading(true) try { @@ -265,6 +337,16 @@ export default function StaffManagement() {
+ {member.role === 'artist' && ( + + )} + + +
) } \ No newline at end of file diff --git a/dev.log b/dev.log deleted file mode 100644 index 17ac56b..0000000 --- a/dev.log +++ /dev/null @@ -1,34 +0,0 @@ - -> anchoros@0.1.0 dev -> next dev -p 2311 - - ▲ Next.js 14.0.4 - - Local: http://localhost:2311 - - Environments: .env.local - - ✓ Ready in 2.1s - ○ Compiling /middleware ... - ✓ Compiled /middleware in 1308ms (102 modules) - ○ Compiling /aperture/login ... - ✓ Compiled /aperture/login in 8s (520 modules) - ○ Compiling /not-found ... - [webpack.cache.PackFileCacheStrategy] Serializing big strings (102kiB) impacts deserialization performance (consider using Buffer instead and decode when needed) - [webpack.cache.PackFileCacheStrategy] Serializing big strings (140kiB) impacts deserialization performance (consider using Buffer instead and decode when needed) - ✓ Compiled /not-found in 6.6s (502 modules) - Reload env: .env - ✓ Compiled in 1282ms (599 modules) - Reload env: .env - ✓ Compiled in 238ms (599 modules) - ○ Compiling /api/aperture/dashboard ... - ✓ Compiled /api/aperture/dashboard in 1187ms (309 modules) -Aperture dashboard GET error: { - code: 'PGRST200', - details: "Searched for a foreign key relationship between 'bookings' and 'customer' in the schema 'public', but no matches were found.", - hint: "Perhaps you meant 'customers' instead of 'customer'.", - message: "Could not find a relationship between 'bookings' and 'customer' in the schema cache" -} - ✓ Compiled in 1251ms (497 modules) - ⚠ Fast Refresh had to perform a full reload. Read more: https://nextjs.org/docs/messages/fast-refresh-reload - ○ Compiling /not-found ... - ✓ Compiled /not-found in 1490ms (502 modules) -[?25h diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts new file mode 100644 index 0000000..22c54bb --- /dev/null +++ b/lib/calendar-utils.ts @@ -0,0 +1,49 @@ +/** + * Calendar utilities for drag & drop operations + * Handles staff service validation, conflict checking, and booking rescheduling + */ + +export const checkStaffCanPerformService = async (staffId: string, serviceId: string): Promise => { + try { + const response = await fetch(`/api/aperture/staff/${staffId}/services`); + const data = await response.json(); + return data.success && data.services.some((s: any) => s.services?.id === serviceId); + } catch (error) { + console.error('Error checking staff services:', error); + return false; + } +}; + +export const checkForConflicts = async (bookingId: string, staffId: string, startTime: string, duration: number): Promise => { + try { + const endTime = new Date(new Date(startTime).getTime() + duration * 60 * 1000).toISOString(); + + // Check staff availability + const response = await fetch('/api/aperture/staff-unavailable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ staff_id: staffId, start_time: startTime, end_time: endTime, exclude_booking_id: bookingId }) + }); + + const data = await response.json(); + return !data.available; // If not available, there's a conflict + } catch (error) { + console.error('Error checking conflicts:', error); + return true; // Assume conflict on error + } +}; + +export const rescheduleBooking = async (bookingId: string, updates: any) => { + try { + const response = await fetch(`/api/aperture/bookings/${bookingId}/reschedule`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + + return await response.json(); + } catch (error) { + console.error('Error rescheduling booking:', error); + return { success: false, error: 'Network error' }; + } +}; \ No newline at end of file diff --git a/lib/email.ts b/lib/email.ts index 641a439..a833511 100644 --- a/lib/email.ts +++ b/lib/email.ts @@ -1,7 +1,29 @@ import { Resend } from 'resend' -const resend = new Resend(process.env.RESEND_API_KEY!) +/** + * @description Email service integration using Resend API for transactional emails + * @audit BUSINESS RULE: Sends HTML-formatted emails with PDF receipt attachments + * @audit SECURITY: Requires RESEND_API_KEY environment variable for authentication + * @audit PERFORMANCE: Uses Resend SDK for reliable email delivery + * @audit AUDIT: Email send results logged for delivery tracking + */ +/** Resend client instance configured with API key */ +const resendClient = new Resend(process.env.RESEND_API_KEY!) + +/** + * @description Interface defining data required for receipt email + * @property {string} to - Recipient email address + * @property {string} customerName - Customer's first name for personalization + * @property {string} bookingId - UUID of the booking for receipt generation + * @property {string} serviceName - Name of the booked service + * @property {string} date - Formatted date of the appointment + * @property {string} time - Formatted time of the appointment + * @property {string} location - Name and address of the salon location + * @property {string} staffName - Assigned staff member name + * @property {number} price - Total price of the service in MXN + * @property {string} pdfUrl - URL path to the generated PDF receipt + */ interface ReceiptEmailData { to: string customerName: string @@ -15,7 +37,16 @@ interface ReceiptEmailData { pdfUrl: string } -/** @description Send receipt email to customer */ +/** + * @description Sends a receipt confirmation email with PDF attachment to the customer + * @param {ReceiptEmailData} data - Email data including customer details and booking information + * @returns {Promise<{ success: boolean; data?: any; error?: any }>} - Result of email send operation + * @example sendReceiptEmail({ to: 'customer@email.com', customerName: 'Ana', bookingId: '...', serviceName: 'Manicure', date: '2026-01-21', time: '10:00', location: 'ANCHOR:23 Saltillo', staffName: 'Maria', price: 1500, pdfUrl: '/receipts/...' }) + * @audit BUSINESS RULE: Sends branded HTML email with ANCHOR:23 styling and Spanish content + * @audit Validate: Attaches PDF receipt with booking ID in filename + * @audit PERFORMANCE: Single API call to Resend with HTML content and attachment + * @audit AUDIT: Email sending logged for customer communication tracking + */ export async function sendReceiptEmail(data: ReceiptEmailData) { try { const emailHtml = ` @@ -75,7 +106,7 @@ export async function sendReceiptEmail(data: ReceiptEmailData) { ` - const { data: result, error } = await resend.emails.send({ + const { data: result, error } = await resendClient.emails.send({ from: 'ANCHOR:23 ', to: data.to, subject: 'Confirmación de Reserva - ANCHOR:23', @@ -99,4 +130,4 @@ export async function sendReceiptEmail(data: ReceiptEmailData) { console.error('Email service error:', error) return { success: false, error } } -} \ No newline at end of file +} diff --git a/lib/utils.ts b/lib/utils.ts index 2122e6d..72f038f 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,7 +2,13 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" /** - * cn function that merges class names using clsx and tailwind-merge. + * @description Utility function that merges and deduplicates CSS class names using clsx and tailwind-merge + * @param {ClassValue[]} inputs - Array of class name values (strings, objects, arrays, or falsy values) + * @returns {string} - Merged CSS class string with Tailwind class conflicts resolved + * @example cn('px-4 py-2', { 'bg-blue-500': true }, ['text-white', 'font-bold']) + * @audit BUSINESS RULE: Resolves Tailwind CSS class conflicts by letting later classes override earlier ones + * @audit PERFORMANCE: Optimized for frequent use in component className props + * @audit Validate: Handles all clsx input types (strings, objects, arrays, nested objects) */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) diff --git a/lib/utils/business-hours.ts b/lib/utils/business-hours.ts index a2614a6..869806b 100644 --- a/lib/utils/business-hours.ts +++ b/lib/utils/business-hours.ts @@ -1,13 +1,37 @@ +/** + * @description Business hours utilities for managing location operating schedules + * @audit BUSINESS RULE: Business hours stored in JSONB format with day keys (sunday-saturday) + * @audit PERFORMANCE: All functions use O(1) lookups and O(n) iteration (max 7 days) + */ + import type { BusinessHours, DayHours } from '@/lib/db/types' +/** Array of day names in lowercase for consistent key access */ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const +/** Type representing valid day of week values */ type DayOfWeek = typeof DAYS[number] +/** + * @description Converts a Date object to its corresponding day of week string + * @param {Date} date - The date to extract day of week from + * @returns {DayOfWeek} - Lowercase day name (e.g., 'monday', 'tuesday') + * @example getDayOfWeek(new Date('2026-01-21')) // returns 'wednesday' + * @audit PERFORMANCE: Uses native getDay() method for O(1) conversion + */ export function getDayOfWeek(date: Date): DayOfWeek { return DAYS[date.getDay()] } export function isOpenNow(businessHours: BusinessHours, date = new Date): boolean { + /** + * @description Checks if the business is currently open based on business hours configuration + * @param {BusinessHours} businessHours - JSON object with day-by-day operating hours + * @param {Date} date - Optional date to check (defaults to current time) + * @returns {boolean} - True if business is open, false if closed + * @example isOpenNow({ monday: { open: '10:00', close: '19:00', is_closed: false } }, new Date()) + * @audit BUSINESS RULE: Compares current time against open/close times in HH:MM format + * @audit Validate: Returns false immediately if day is marked as is_closed + */ const day = getDayOfWeek(date) const hours = businessHours[day] @@ -29,6 +53,15 @@ export function isOpenNow(businessHours: BusinessHours, date = new Date): boolea } export function getNextOpenTime(businessHours: BusinessHours, from = new Date): Date | null { + /** + * @description Finds the next opening time within the next 7 days + * @param {BusinessHours} businessHours - JSON object with day-by-day operating hours + * @param {Date} from - Reference date to search from (defaults to current time) + * @returns {Date | null} - Next opening DateTime or null if no opening found within 7 days + * @example getNextOpenTime({ monday: { open: '10:00', close: '19:00' }, sunday: { is_closed: true } }) + * @audit BUSINESS RULE: Scans up to 7 days ahead to find next available opening + * @audit PERFORMANCE: O(7) iteration worst case, exits early when found + */ const checkDate = new Date(from) for (let i = 0; i < 7; i++) { @@ -56,6 +89,15 @@ export function getNextOpenTime(businessHours: BusinessHours, from = new Date): } export function isTimeWithinHours(time: string, dayHours: DayHours): boolean { + /** + * @description Validates if a given time falls within operating hours for a specific day + * @param {string} time - Time in HH:MM format (e.g., '14:30') + * @param {DayHours} dayHours - Operating hours for a single day with open, close, and is_closed + * @returns {boolean} - True if time is within operating hours, false otherwise + * @example isTimeWithinHours('14:30', { open: '10:00', close: '19:00', is_closed: false }) // true + * @audit BUSINESS RULE: Converts times to minutes for accurate comparison + * @audit Validate: Returns false immediately if dayHours.is_closed is true + */ if (dayHours.is_closed) { return false } @@ -72,6 +114,13 @@ export function isTimeWithinHours(time: string, dayHours: DayHours): boolean { } export function getBusinessHoursString(dayHours: DayHours): string { + /** + * @description Formats day hours for display in UI + * @param {DayHours} dayHours - Operating hours for a single day + * @returns {string} - Formatted string (e.g., '10:00 - 19:00' or 'Cerrado') + * @example getBusinessHoursString({ open: '10:00', close: '19:00', is_closed: false }) // '10:00 - 19:00' + * @audit BUSINESS RULE: Returns 'Cerrado' (Spanish for closed) when is_closed is true + */ if (dayHours.is_closed) { return 'Cerrado' } @@ -79,6 +128,13 @@ export function getBusinessHoursString(dayHours: DayHours): string { } export function getTodayHours(businessHours: BusinessHours): string { + /** + * @description Gets formatted operating hours for the current day + * @param {BusinessHours} businessHours - JSON object with day-by-day operating hours + * @returns {string} - Formatted hours string for today (e.g., '10:00 - 19:00' or 'Cerrado') + * @example getTodayHours(businessHoursConfig) // Returns hours for current day of week + * @audit PERFORMANCE: Single lookup using getDayOfWeek on current date + */ const day = getDayOfWeek(new Date()) return getBusinessHoursString(businessHours[day]) } diff --git a/lib/webhook.ts b/lib/webhook.ts index 1ed1675..7ad9819 100644 --- a/lib/webhook.ts +++ b/lib/webhook.ts @@ -1,8 +1,22 @@ +/** + * @description Webhook utility for sending HTTP POST notifications to external services + * @audit BUSINESS RULE: Sends payloads to multiple webhook endpoints for redundancy + * @audit SECURITY: Endpoints configured via environment constants (not exposed to client) + */ + +/** Array of webhook endpoint URLs for sending notifications */ export const WEBHOOK_ENDPOINTS = [ 'https://flows.soul23.cloud/webhook-test/4YZ7RPfo1GT', 'https://flows.soul23.cloud/webhook/4YZ7RPfo1GT' ] +/** + * @description Detects the current device type based on viewport width + * @returns {string} - Device type: 'mobile' (≤768px), 'desktop' (>768px), or 'unknown' (server-side) + * @example getDeviceType() // returns 'desktop' or 'mobile' + * @audit PERFORMANCE: Uses native window.matchMedia for client-side detection + * @audit Validate: Returns 'unknown' when running server-side (typeof window === 'undefined') + */ export const getDeviceType = () => { if (typeof window === 'undefined') { return 'unknown' @@ -11,6 +25,17 @@ export const getDeviceType = () => { return window.matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop' } +/** + * @description Sends a webhook payload to all configured endpoints with fallback redundancy + * @param {Record} payload - Key-value data to send in webhook request body + * @returns {Promise} - Resolves if at least one endpoint receives the payload successfully + * @example await sendWebhookPayload({ event: 'booking_created', bookingId: '...' }) + * @audit BUSINESS RULE: Uses Promise.allSettled to attempt all endpoints and succeed if any succeed + * @audit SECURITY: Sends JSON content type with stringified payload + * @audit Validate: Throws error if ALL endpoints fail (no successful responses) + * @audit PERFORMANCE: Parallel execution to all endpoints for fast delivery + * @audit AUDIT: Webhook delivery attempts logged for debugging + */ export const sendWebhookPayload = async (payload: Record) => { const results = await Promise.allSettled( WEBHOOK_ENDPOINTS.map(async (endpoint) => { diff --git a/ralphy.sh b/ralphy.sh deleted file mode 100755 index 2cd0548..0000000 --- a/ralphy.sh +++ /dev/null @@ -1,2382 +0,0 @@ -#!/usr/bin/env bash - -# ============================================ -# Ralphy - Autonomous AI Coding Loop -# Supports Claude Code, OpenCode, Codex, Cursor, Qwen-Code and Factory Droid -# Runs until PRD is complete -# ============================================ - -set -euo pipefail - -# ============================================ -# CONFIGURATION & DEFAULTS -# ============================================ - -VERSION="4.0.0" - -# Ralphy config directory -RALPHY_DIR=".ralphy" -PROGRESS_FILE="$RALPHY_DIR/progress.txt" -CONFIG_FILE="$RALPHY_DIR/config.yaml" -SINGLE_TASK="" -INIT_MODE=false -SHOW_CONFIG=false -ADD_RULE="" -AUTO_COMMIT=true - -# Runtime options -SKIP_TESTS=false -SKIP_LINT=false -AI_ENGINE="opencode" # claude, opencode, cursor, codex, qwen, or droid -DRY_RUN=false -MAX_ITERATIONS=0 # 0 = unlimited -MAX_RETRIES=3 -RETRY_DELAY=5 -VERBOSE=false - -# Git branch options -BRANCH_PER_TASK=false -CREATE_PR=false -BASE_BRANCH="" -PR_DRAFT=false - -# Parallel execution -PARALLEL=false -MAX_PARALLEL=3 - -# PRD source options -PRD_SOURCE="markdown" # markdown, yaml, github -PRD_FILE="PRD.md" -GITHUB_REPO="" -GITHUB_LABEL="" - -# Colors (detect if terminal supports colors) -if [[ -t 1 ]] && command -v tput &>/dev/null && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then - RED=$(tput setaf 1) - GREEN=$(tput setaf 2) - YELLOW=$(tput setaf 3) - BLUE=$(tput setaf 4) - MAGENTA=$(tput setaf 5) - CYAN=$(tput setaf 6) - BOLD=$(tput bold) - DIM=$(tput dim) - RESET=$(tput sgr0) -else - RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" BOLD="" DIM="" RESET="" -fi - -# Global state -ai_pid="" -monitor_pid="" -tmpfile="" -CODEX_LAST_MESSAGE_FILE="" -current_step="Thinking" -total_input_tokens=0 -total_output_tokens=0 -total_actual_cost="0" # OpenCode provides actual cost -total_duration_ms=0 # Cursor provides duration -iteration=0 -retry_count=0 -declare -a parallel_pids=() -declare -a task_branches=() -declare -a integration_branches=() # Track integration branches for cleanup on interrupt -WORKTREE_BASE="" # Base directory for parallel agent worktrees -ORIGINAL_DIR="" # Original working directory (for worktree operations) -ORIGINAL_BASE_BRANCH="" # Original base branch before integration branches - -# ============================================ -# UTILITY FUNCTIONS -# ============================================ - -log_info() { - echo "${BLUE}[INFO]${RESET} $*" -} - -log_success() { - echo "${GREEN}[OK]${RESET} $*" -} - -log_warn() { - echo "${YELLOW}[WARN]${RESET} $*" -} - -log_error() { - echo "${RED}[ERROR]${RESET} $*" >&2 -} - -log_debug() { - if [[ "$VERBOSE" == true ]]; then - echo "${DIM}[DEBUG] $*${RESET}" - fi -} - -# Slugify text for branch names -slugify() { - echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-|-$//g' | cut -c1-50 -} - -# ============================================ -# BROWNFIELD MODE (.ralphy/ configuration) -# ============================================ - -# Initialize .ralphy/ directory with config files -init_ralphy_config() { - if [[ -d "$RALPHY_DIR" ]]; then - log_warn "$RALPHY_DIR already exists" - REPLY='N' # Default if read times out or fails - read -p "Overwrite config? [y/N] " -n 1 -r -t 30 2>/dev/null || true - echo - [[ ! $REPLY =~ ^[Yy]$ ]] && exit 0 - fi - - mkdir -p "$RALPHY_DIR" - - # Smart detection - local project_name="" - local lang="" - local framework="" - local test_cmd="" - local lint_cmd="" - local build_cmd="" - - # Get project name from directory or package.json - project_name=$(basename "$PWD") - - if [[ -f "package.json" ]]; then - # Get name from package.json if available - local pkg_name - pkg_name=$(jq -r '.name // ""' package.json 2>/dev/null) - [[ -n "$pkg_name" ]] && project_name="$pkg_name" - - # Detect language - if [[ -f "tsconfig.json" ]]; then - lang="TypeScript" - else - lang="JavaScript" - fi - - # Detect frameworks from dependencies (collect all matches) - local deps frameworks=() - deps=$(jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' package.json 2>/dev/null || true) - - # Use grep for reliable exact matching - echo "$deps" | grep -qx "next" && frameworks+=("Next.js") - echo "$deps" | grep -qx "nuxt" && frameworks+=("Nuxt") - echo "$deps" | grep -qx "@remix-run/react" && frameworks+=("Remix") - echo "$deps" | grep -qx "svelte" && frameworks+=("Svelte") - echo "$deps" | grep -qE "@nestjs/" && frameworks+=("NestJS") - echo "$deps" | grep -qx "hono" && frameworks+=("Hono") - echo "$deps" | grep -qx "fastify" && frameworks+=("Fastify") - echo "$deps" | grep -qx "express" && frameworks+=("Express") - # Only add React/Vue if no meta-framework detected - if [[ ${#frameworks[@]} -eq 0 ]]; then - echo "$deps" | grep -qx "react" && frameworks+=("React") - echo "$deps" | grep -qx "vue" && frameworks+=("Vue") - fi - - # Join frameworks with comma - framework=$(IFS=', '; echo "${frameworks[*]}") - - # Detect commands from package.json scripts - local scripts - scripts=$(jq -r '.scripts // {}' package.json 2>/dev/null) - - # Test command (prefer bun if lockfile exists) - if echo "$scripts" | jq -e '.test' >/dev/null 2>&1; then - test_cmd="npm test" - [[ -f "bun.lockb" ]] && test_cmd="bun test" - fi - - # Lint command - if echo "$scripts" | jq -e '.lint' >/dev/null 2>&1; then - lint_cmd="npm run lint" - fi - - # Build command - if echo "$scripts" | jq -e '.build' >/dev/null 2>&1; then - build_cmd="npm run build" - fi - - elif [[ -f "pyproject.toml" ]] || [[ -f "requirements.txt" ]] || [[ -f "setup.py" ]]; then - lang="Python" - local py_frameworks=() - local py_deps="" - [[ -f "pyproject.toml" ]] && py_deps=$(cat pyproject.toml 2>/dev/null) - [[ -f "requirements.txt" ]] && py_deps+=$(cat requirements.txt 2>/dev/null) - echo "$py_deps" | grep -qi "fastapi" && py_frameworks+=("FastAPI") - echo "$py_deps" | grep -qi "django" && py_frameworks+=("Django") - echo "$py_deps" | grep -qi "flask" && py_frameworks+=("Flask") - framework=$(IFS=', '; echo "${py_frameworks[*]}") - test_cmd="pytest" - lint_cmd="ruff check ." - - elif [[ -f "go.mod" ]]; then - lang="Go" - test_cmd="go test ./..." - lint_cmd="golangci-lint run" - - elif [[ -f "Cargo.toml" ]]; then - lang="Rust" - test_cmd="cargo test" - lint_cmd="cargo clippy" - build_cmd="cargo build" - fi - - # Show what we detected - echo "" - echo "${BOLD}Detected:${RESET}" - echo " Project: ${CYAN}$project_name${RESET}" - [[ -n "$lang" ]] && echo " Language: ${CYAN}$lang${RESET}" - [[ -n "$framework" ]] && echo " Framework: ${CYAN}$framework${RESET}" - [[ -n "$test_cmd" ]] && echo " Test: ${CYAN}$test_cmd${RESET}" - [[ -n "$lint_cmd" ]] && echo " Lint: ${CYAN}$lint_cmd${RESET}" - [[ -n "$build_cmd" ]] && echo " Build: ${CYAN}$build_cmd${RESET}" - echo "" - - # Escape values for safe YAML (double quotes inside strings) - yaml_escape() { printf '%s' "$1" | sed 's/"/\\"/g'; } - - # Create config.yaml with detected values - cat > "$CONFIG_FILE" << EOF -# Ralphy Configuration -# https://github.com/michaelshimeles/ralphy - -# Project info (auto-detected, edit if needed) -project: - name: "$(yaml_escape "$project_name")" - language: "$(yaml_escape "${lang:-Unknown}")" - framework: "$(yaml_escape "${framework:-}")" - description: "" # Add a brief description - -# Commands (auto-detected from package.json/pyproject.toml) -commands: - test: "$(yaml_escape "${test_cmd:-}")" - lint: "$(yaml_escape "${lint_cmd:-}")" - build: "$(yaml_escape "${build_cmd:-}")" - -# Rules - instructions the AI MUST follow -# These are injected into every prompt -rules: [] - # Examples: - # - "Always use TypeScript strict mode" - # - "Follow the error handling pattern in src/utils/errors.ts" - # - "All API endpoints must have input validation with Zod" - # - "Use server actions instead of API routes in Next.js" - -# Boundaries - files/folders the AI should not modify -boundaries: - never_touch: [] - # Examples: - # - "src/legacy/**" - # - "migrations/**" - # - "*.lock" -EOF - - # Create progress.txt - echo "# Ralphy Progress Log" > "$PROGRESS_FILE" - echo "" >> "$PROGRESS_FILE" - - log_success "Created $RALPHY_DIR/" - echo "" - echo " ${CYAN}$CONFIG_FILE${RESET} - Your rules and preferences" - echo " ${CYAN}$PROGRESS_FILE${RESET} - Progress log (auto-updated)" - echo "" - echo "${BOLD}Next steps:${RESET}" - echo " 1. Add rules: ${CYAN}ralphy --add-rule \"your rule here\"${RESET}" - echo " 2. Or edit: ${CYAN}$CONFIG_FILE${RESET}" - echo " 3. Run: ${CYAN}ralphy \"your task\"${RESET} or ${CYAN}ralphy${RESET} (with PRD.md)" -} - -# Load rules from config.yaml -load_ralphy_rules() { - [[ ! -f "$CONFIG_FILE" ]] && return - - if command -v yq &>/dev/null; then - yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null || true - fi -} - -# Load boundaries from config.yaml -load_ralphy_boundaries() { - local boundary_type="$1" # never_touch or always_test - [[ ! -f "$CONFIG_FILE" ]] && return - - if command -v yq &>/dev/null; then - yq -r ".boundaries.$boundary_type // [] | .[]" "$CONFIG_FILE" 2>/dev/null || true - fi -} - -# Show current config -show_ralphy_config() { - if [[ ! -f "$CONFIG_FILE" ]]; then - log_warn "No config found. Run 'ralphy --init' first." - exit 1 - fi - - echo "" - echo "${BOLD}Ralphy Configuration${RESET} ($CONFIG_FILE)" - echo "" - - if command -v yq &>/dev/null; then - # Project info - local name lang framework desc - name=$(yq -r '.project.name // "Unknown"' "$CONFIG_FILE" 2>/dev/null) - lang=$(yq -r '.project.language // "Unknown"' "$CONFIG_FILE" 2>/dev/null) - framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null) - desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null) - - echo "${BOLD}Project:${RESET}" - echo " Name: $name" - echo " Language: $lang" - [[ -n "$framework" ]] && echo " Framework: $framework" - [[ -n "$desc" ]] && echo " About: $desc" - echo "" - - # Commands - local test_cmd lint_cmd build_cmd - test_cmd=$(yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null) - lint_cmd=$(yq -r '.commands.lint // ""' "$CONFIG_FILE" 2>/dev/null) - build_cmd=$(yq -r '.commands.build // ""' "$CONFIG_FILE" 2>/dev/null) - - echo "${BOLD}Commands:${RESET}" - [[ -n "$test_cmd" ]] && echo " Test: $test_cmd" || echo " Test: ${DIM}(not set)${RESET}" - [[ -n "$lint_cmd" ]] && echo " Lint: $lint_cmd" || echo " Lint: ${DIM}(not set)${RESET}" - [[ -n "$build_cmd" ]] && echo " Build: $build_cmd" || echo " Build: ${DIM}(not set)${RESET}" - echo "" - - # Rules - echo "${BOLD}Rules:${RESET}" - local rules - rules=$(yq -r '.rules // [] | .[]' "$CONFIG_FILE" 2>/dev/null) - if [[ -n "$rules" ]]; then - echo "$rules" | while read -r rule; do - echo " • $rule" - done - else - echo " ${DIM}(none - add with: ralphy --add-rule \"...\")${RESET}" - fi - echo "" - - # Boundaries - local never_touch - never_touch=$(yq -r '.boundaries.never_touch // [] | .[]' "$CONFIG_FILE" 2>/dev/null) - if [[ -n "$never_touch" ]]; then - echo "${BOLD}Never Touch:${RESET}" - echo "$never_touch" | while read -r path; do - echo " • $path" - done - echo "" - fi - else - # Fallback: just show the file - cat "$CONFIG_FILE" - fi -} - -# Add a rule to config.yaml -add_ralphy_rule() { - local rule="$1" - - if [[ ! -f "$CONFIG_FILE" ]]; then - log_error "No config found. Run 'ralphy --init' first." - exit 1 - fi - - if ! command -v yq &>/dev/null; then - log_error "yq is required to add rules. Install from https://github.com/mikefarah/yq" - log_info "Or manually edit $CONFIG_FILE" - exit 1 - fi - - # Add rule to the rules array (use env var to avoid YAML injection) - RULE="$rule" yq -i '.rules += [env(RULE)]' "$CONFIG_FILE" - log_success "Added rule: $rule" -} - -# Load test command from config -load_test_command() { - [[ ! -f "$CONFIG_FILE" ]] && echo "" && return - - if command -v yq &>/dev/null; then - yq -r '.commands.test // ""' "$CONFIG_FILE" 2>/dev/null || echo "" - else - echo "" - fi -} - -# Load project context from config.yaml -load_project_context() { - [[ ! -f "$CONFIG_FILE" ]] && return - - if command -v yq &>/dev/null; then - local name lang framework desc - name=$(yq -r '.project.name // ""' "$CONFIG_FILE" 2>/dev/null) - lang=$(yq -r '.project.language // ""' "$CONFIG_FILE" 2>/dev/null) - framework=$(yq -r '.project.framework // ""' "$CONFIG_FILE" 2>/dev/null) - desc=$(yq -r '.project.description // ""' "$CONFIG_FILE" 2>/dev/null) - - local context="" - [[ -n "$name" ]] && context+="Project: $name\n" - [[ -n "$lang" ]] && context+="Language: $lang\n" - [[ -n "$framework" ]] && context+="Framework: $framework\n" - [[ -n "$desc" ]] && context+="Description: $desc\n" - echo -e "$context" - fi -} - -# Log task to progress file -log_task_history() { - local task="$1" - local status="$2" # completed, failed - - [[ ! -f "$PROGRESS_FILE" ]] && return - - local timestamp - timestamp=$(date '+%Y-%m-%d %H:%M') - local icon="✓" - [[ "$status" == "failed" ]] && icon="✗" - - echo "- [$icon] $timestamp - $task" >> "$PROGRESS_FILE" -} - -# Build prompt with brownfield context -build_brownfield_prompt() { - local task="$1" - local prompt="" - - # Add project context if available - local context - context=$(load_project_context) - if [[ -n "$context" ]]; then - prompt+="## Project Context -$context - -" - fi - - # Add rules if available - local rules - rules=$(load_ralphy_rules) - if [[ -n "$rules" ]]; then - prompt+="## Rules (you MUST follow these) -$rules - -" - fi - - # Add boundaries - local never_touch - never_touch=$(load_ralphy_boundaries "never_touch") - if [[ -n "$never_touch" ]]; then - prompt+="## Boundaries -Do NOT modify these files/directories: -$never_touch - -" - fi - - # Add the task - prompt+="## Task -$task - -## Progress -$(cat "$PROGRESS_FILE") - -## Instructions -1. Implement the task described above -2. Write tests if appropriate -3. Ensure the code works correctly" - - # Add commit instruction only if auto-commit is enabled - if [[ "$AUTO_COMMIT" == "true" ]]; then - prompt+=" -4. Commit your changes with a descriptive message" - fi - - prompt+=" - -Keep changes focused and minimal. Do not refactor unrelated code." - - echo "$prompt" -} - -# Run a single brownfield task -run_brownfield_task() { - local task="$1" - - echo "" - echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" - echo "${BOLD}Task:${RESET} $task" - echo "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" - echo "" - - local prompt - prompt=$(build_brownfield_prompt "$task") - - # Create temp file for output - local output_file - output_file=$(mktemp) - - log_info "Running with $AI_ENGINE..." - - # Run the AI engine (tee to show output while saving for parsing) - case "$AI_ENGINE" in - claude) - claude --dangerously-skip-permissions \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - opencode) - opencode --output-format stream-json \ - --approval-mode full-auto \ - "$prompt" 2>&1 | tee "$output_file" - ;; - cursor) - agent --dangerously-skip-permissions \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - qwen) - qwen --output-format stream-json \ - --approval-mode yolo \ - -p "$prompt" 2>&1 | tee "$output_file" - ;; - droid) - droid exec --output-format stream-json \ - --auto medium \ - "$prompt" 2>&1 | tee "$output_file" - ;; - codex) - codex exec --full-auto \ - --json \ - "$prompt" 2>&1 | tee "$output_file" - ;; - esac - - local exit_code=$? - - # Log to history - if [[ $exit_code -eq 0 ]]; then - log_task_history "$task" "completed" - log_success "Task completed" - else - log_task_history "$task" "failed" - log_error "Task failed" - fi - - rm -f "$output_file" - return $exit_code -} - -# ============================================ -# HELP & VERSION -# ============================================ - -show_help() { - cat << EOF -${BOLD}Ralphy${RESET} - Autonomous AI Coding Loop (v${VERSION}) - -${BOLD}USAGE:${RESET} - ./ralphy.sh [options] # PRD mode (requires PRD.md) - ./ralphy.sh "task description" # Single task mode (brownfield) - ./ralphy.sh --init # Initialize .ralphy/ config - -${BOLD}CONFIG & SETUP:${RESET} - --init Initialize .ralphy/ with smart defaults - --config Show current configuration - --add-rule "..." Add a rule to config (e.g., "Always use Zod") - -${BOLD}SINGLE TASK MODE:${RESET} - "task description" Run a single task without PRD (quotes required) - --no-commit Don't auto-commit after task completion - -${BOLD}AI ENGINE OPTIONS:${RESET} - --claude Use Claude Code (default) - --opencode Use OpenCode - --cursor Use Cursor agent - --codex Use Codex CLI - --qwen Use Qwen-Code - --droid Use Factory Droid - -${BOLD}WORKFLOW OPTIONS:${RESET} - --no-tests Skip writing and running tests - --no-lint Skip linting - --fast Skip both tests and linting - -${BOLD}EXECUTION OPTIONS:${RESET} - --max-iterations N Stop after N iterations (0 = unlimited) - --max-retries N Max retries per task on failure (default: 3) - --retry-delay N Seconds between retries (default: 5) - --dry-run Show what would be done without executing - -${BOLD}PARALLEL EXECUTION:${RESET} - --parallel Run independent tasks in parallel - --max-parallel N Max concurrent tasks (default: 3) - -${BOLD}GIT BRANCH OPTIONS:${RESET} - --branch-per-task Create a new git branch for each task - --base-branch NAME Base branch to create task branches from (default: current) - --create-pr Create a pull request after each task (requires gh CLI) - --draft-pr Create PRs as drafts - -${BOLD}PRD SOURCE OPTIONS:${RESET} - --prd FILE PRD file path (default: PRD.md) - --yaml FILE Use YAML task file instead of markdown - --github REPO Fetch tasks from GitHub issues (e.g., owner/repo) - --github-label TAG Filter GitHub issues by label - -${BOLD}OTHER OPTIONS:${RESET} - -v, --verbose Show debug output - -h, --help Show this help - --version Show version number - -${BOLD}EXAMPLES:${RESET} - # Brownfield mode (single tasks in existing projects) - ./ralphy.sh --init # Initialize config - ./ralphy.sh "add dark mode toggle" # Run single task - ./ralphy.sh "fix the login bug" --cursor # Single task with Cursor - - # PRD mode (task lists) - ./ralphy.sh # Run with Claude Code - ./ralphy.sh --codex # Run with Codex CLI - ./ralphy.sh --branch-per-task --create-pr # Feature branch workflow - ./ralphy.sh --parallel --max-parallel 4 # Run 4 tasks concurrently - ./ralphy.sh --yaml tasks.yaml # Use YAML task file - ./ralphy.sh --github owner/repo # Fetch from GitHub issues - -${BOLD}PRD FORMATS:${RESET} - Markdown (PRD.md): - - [ ] Task description - - YAML (tasks.yaml): - tasks: - - title: Task description - completed: false - parallel_group: 1 # Optional: tasks with same group run in parallel - - GitHub Issues: - Uses open issues from the specified repository - -EOF -} - -show_version() { - echo "Ralphy v${VERSION}" -} - -# ============================================ -# ARGUMENT PARSING -# ============================================ - -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - --no-tests|--skip-tests) - SKIP_TESTS=true - shift - ;; - --no-lint|--skip-lint) - SKIP_LINT=true - shift - ;; - --fast) - SKIP_TESTS=true - SKIP_LINT=true - shift - ;; - --opencode) - AI_ENGINE="opencode" - shift - ;; - --claude) - AI_ENGINE="claude" - shift - ;; - --cursor|--agent) - AI_ENGINE="cursor" - shift - ;; - --codex) - AI_ENGINE="codex" - shift - ;; - --qwen) - AI_ENGINE="qwen" - shift - ;; - --droid) - AI_ENGINE="droid" - shift - ;; - --init) - INIT_MODE=true - shift - ;; - --config) - SHOW_CONFIG=true - shift - ;; - --add-rule) - ADD_RULE="$2" - shift 2 - ;; - --no-commit) - AUTO_COMMIT=false - shift - ;; - --max-iterations) - MAX_ITERATIONS="$2" - shift 2 - ;; - --max-retries) - MAX_RETRIES="$2" - shift 2 - ;; - --retry-delay) - RETRY_DELAY="$2" - shift 2 - ;; - --dry-run) - DRY_RUN=true - shift - ;; - --parallel) - PARALLEL=true - shift - ;; - --max-parallel) - MAX_PARALLEL="$2" - shift 2 - ;; - --branch-per-task) - BRANCH_PER_TASK=true - shift - ;; - --base-branch) - BASE_BRANCH="$2" - shift 2 - ;; - --create-pr) - CREATE_PR=true - shift - ;; - --draft-pr) - PR_DRAFT=true - shift - ;; - --prd) - PRD_FILE="$2" - shift 2 - ;; - --yaml) - PRD_SOURCE="yaml" - PRD_FILE="$2" - shift 2 - ;; - --github) - PRD_SOURCE="github" - GITHUB_REPO="$2" - shift 2 - ;; - --github-label) - GITHUB_LABEL="$2" - shift 2 - ;; - -v|--verbose) - VERBOSE=true - shift - ;; - -h|--help) - show_help - exit 0 - ;; - --version) - show_version - exit 0 - ;; - *) - SINGLE_TASK="$1" - shift - ;; - esac - done - - # Validate arguments - if [[ "$INIT_MODE" == true ]]; then - init_ralphy_config - exit 0 - fi - - if [[ "$SHOW_CONFIG" == true ]]; then - show_ralphy_config - exit 0 - fi - - if [[ -n "$ADD_RULE" ]]; then - add_ralphy_rule "$ADD_RULE" - exit 0 - fi - - # If no single task and no PRD file, show help - if [[ -z "$SINGLE_TASK" ]] && [[ ! -f "$PRD_FILE" ]]; then - if [[ "$PRD_SOURCE" == "github" ]]; then - # GitHub mode doesn't need local file - : - else - echo "No PRD file found. Run './ralphy.sh --init' to create config, or create PRD.md, or specify a task." - exit 1 - fi - fi - -# ============================================ -# PRE-FLIGHT CHECKS -# ============================================ - -check_requirements() { - local missing=() - - # Check for PRD source - case "$PRD_SOURCE" in - markdown) - if [[ ! -f "$PRD_FILE" ]]; then - log_error "$PRD_FILE not found in current directory" - log_info "Create a PRD.md file with tasks marked as '- [ ] Task description'" - log_info "Or use: --yaml tasks.yaml for YAML task files" - exit 1 - fi - ;; - yaml) - if [[ ! -f "$PRD_FILE" ]]; then - log_error "$PRD_FILE not found in current directory" - log_info "Create a tasks.yaml file with tasks in YAML format" - log_info "Or use: --prd PRD.md for Markdown task files" - exit 1 - fi - if ! command -v yq &>/dev/null; then - log_error "yq is required for YAML parsing. Install from https://github.com/mikefarah/yq" - exit 1 - fi - ;; - github) - if [[ -z "$GITHUB_REPO" ]]; then - log_error "GitHub repository not specified. Use --github owner/repo" - exit 1 - fi - if ! command -v gh &>/dev/null; then - log_error "GitHub CLI (gh) is required. Install from https://cli.github.com/" - exit 1 - fi - ;; - esac - - # Check for AI CLI - case "$AI_ENGINE" in - opencode) - if ! command -v opencode &>/dev/null; then - log_error "OpenCode CLI not found." - log_info "Install from: https://opencode.ai/docs/" - exit 1 - fi - ;; - codex) - if ! command -v codex &>/dev/null; then - log_error "Codex CLI not found." - log_info "Make sure 'codex' is in your PATH." - exit 1 - fi - ;; - cursor) - if ! command -v agent &>/dev/null; then - log_error "Cursor agent CLI not found." - log_info "Make sure Cursor is installed and 'agent' is in your PATH." - exit 1 - fi - ;; - qwen) - if ! command -v qwen &>/dev/null; then - log_error "Qwen-Code CLI not found." - log_info "Make sure 'qwen' is in your PATH." - exit 1 - fi - ;; - droid) - if ! command -v droid &>/dev/null; then - log_error "Factory Droid CLI not found. Install from https://docs.factory.ai/cli/getting-started/quickstart" - exit 1 - fi - ;; - *) - if ! command -v claude &>/dev/null; then - log_error "Claude Code CLI not found." - log_info "Install from: https://github.com/anthropics/claude-code" - log_info "Or use another engine: --cursor, --opencode, --codex, --qwen" - exit 1 - fi - ;; - esac - - # Check for jq (required for JSON parsing) - if ! command -v jq &>/dev/null; then - log_error "jq is required but not installed. On Linux, install with: apt-get install jq (Debian/Ubuntu) or yum install jq (RHEL/CentOS)" - exit 1 - fi - - # Check for gh if PR creation is requested - if [[ "$CREATE_PR" == true ]] && ! command -v gh &>/dev/null; then - log_error "GitHub CLI (gh) is required for --create-pr. Install from https://cli.github.com/" - exit 1 - fi - - if [[ ${#missing[@]} -gt 0 ]]; then - log_warn "Missing optional dependencies: ${missing[*]}" - log_warn "Some features may not work properly" - fi - - # Check for git - if ! command -v git &>/dev/null; then - log_error "git is required but not installed. Ralphy requires a git repository to track changes." - exit 1 - fi - - # Check if we're in a git repository - if ! git rev-parse --git-dir >/dev/null 2>&1; then - log_error "Not a git repository. Ralphy requires a git repository to track changes." - exit 1 - fi - - # Ensure .ralphy/ directory exists and create progress.txt if missing - mkdir -p "$RALPHY_DIR" - if [[ ! -f "$PROGRESS_FILE" ]]; then - log_info "Creating $PROGRESS_FILE..." - echo "# Ralphy Progress Log" > "$PROGRESS_FILE" - echo "" >> "$PROGRESS_FILE" - fi - - # Set base branch if not specified - if [[ "$BRANCH_PER_TASK" == true ]] && [[ -z "$BASE_BRANCH" ]]; then - BASE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main") - log_debug "Using base branch: $BASE_BRANCH" - fi -} - -# ============================================ -# ARGUMENT PARSING END -# ============================================ -} - -# ============================================ -# CLEANUP HANDLER -# ============================================ - -cleanup() { - local exit_code=$? - - # Kill background processes - [[ -n "$monitor_pid" ]] && kill "$monitor_pid" 2>/dev/null || true - [[ -n "$ai_pid" ]] && kill "$ai_pid" 2>/dev/null || true - - # Kill parallel processes - for pid in "${parallel_pids[@]+"${parallel_pids[@]}"}"; do - kill "$pid" 2>/dev/null || true - done - - # Kill any remaining child processes - pkill -P $$ 2>/dev/null || true - - # Remove temp file - [[ -n "$tmpfile" ]] && rm -f "$tmpfile" - [[ -n "$CODEX_LAST_MESSAGE_FILE" ]] && rm -f "$CODEX_LAST_MESSAGE_FILE" - - # Cleanup parallel worktrees - if [[ -n "$WORKTREE_BASE" ]] && [[ -d "$WORKTREE_BASE" ]]; then - # Remove all worktrees we created - for dir in "$WORKTREE_BASE"/agent-*; do - if [[ -d "$dir" ]]; then - if git -C "$dir" status --porcelain 2>/dev/null | grep -q .; then - log_warn "Preserving dirty worktree: $dir" - continue - fi - git worktree remove "$dir" 2>/dev/null || true - fi - done - if ! find "$WORKTREE_BASE" -maxdepth 1 -type d -name 'agent-*' -print -quit 2>/dev/null | grep -q .; then - rm -rf "$WORKTREE_BASE" 2>/dev/null || true - else - log_warn "Preserving worktree base with dirty agents: $WORKTREE_BASE" - fi - fi - - # Show message on interrupt - if [[ $exit_code -eq 130 ]]; then - printf "\n" - log_warn "Interrupted! Cleaned up." - - # Show branches created if any - if [[ -n "${task_branches[*]+"${task_branches[*]}"}" ]]; then - log_info "Branches created: ${task_branches[*]}" - fi - - # Show integration branches if any (for parallel group workflows) - if [[ -n "${integration_branches[*]+"${integration_branches[*]}"}" ]]; then - log_info "Integration branches: ${integration_branches[*]}" - if [[ -n "$ORIGINAL_BASE_BRANCH" ]]; then - log_info "To resume: merge integration branches into $ORIGINAL_BASE_BRANCH" - fi - fi - fi -} - -# ============================================ -# TASK SOURCES - MARKDOWN -# ============================================ - -get_tasks_markdown() { - grep '^\- \[ \]' "$PRD_FILE" 2>/dev/null | sed 's/^- \[ \] //' || true -} - -get_next_task_markdown() { - grep -m1 '^\- \[ \]' "$PRD_FILE" 2>/dev/null | sed 's/^- \[ \] //' | cut -c1-50 || echo "" -} - -count_remaining_markdown() { - grep -c '^\- \[ \]' "$PRD_FILE" 2>/dev/null || echo "0" -} - -count_completed_markdown() { - grep -c '^\- \[x\]' "$PRD_FILE" 2>/dev/null || echo "0" -} - -mark_task_complete_markdown() { - local task=$1 - # For macOS sed (BRE), we need to: - # - Escape: [ ] \ . * ^ $ / - # - NOT escape: { } ( ) + ? | (these are literal in BRE) - local escaped_task - escaped_task=$(printf '%s\n' "$task" | sed 's/[[\.*^$/]/\\&/g') - sed -i.bak "s/^- \[ \] ${escaped_task}/- [x] ${escaped_task}/" "$PRD_FILE" - rm -f "${PRD_FILE}.bak" -} - -# ============================================ -# TASK SOURCES - YAML -# ============================================ - -get_tasks_yaml() { - yq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null || true -} - -get_next_task_yaml() { - yq -r '.tasks[] | select(.completed != true) | .title' "$PRD_FILE" 2>/dev/null | head -1 | cut -c1-50 || echo "" -} - -count_remaining_yaml() { - yq -r '[.tasks[] | select(.completed != true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" -} - -count_completed_yaml() { - yq -r '[.tasks[] | select(.completed == true)] | length' "$PRD_FILE" 2>/dev/null || echo "0" -} - -mark_task_complete_yaml() { - local task=$1 - yq -i "(.tasks[] | select(.title == \"$task\")).completed = true" "$PRD_FILE" -} - -get_parallel_group_yaml() { - local task=$1 - yq -r ".tasks[] | select(.title == \"$task\") | .parallel_group // 0" "$PRD_FILE" 2>/dev/null || echo "0" -} - -get_tasks_in_group_yaml() { - local group=$1 - yq -r ".tasks[] | select(.completed != true and (.parallel_group // 0) == $group) | .title" "$PRD_FILE" 2>/dev/null || true -} - -# ============================================ -# TASK SOURCES - GITHUB ISSUES -# ============================================ - -get_tasks_github() { - local args=(--repo "$GITHUB_REPO" --state open --json number,title) - [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") - - gh issue list "${args[@]}" \ - --jq '.[] | "\(.number):\(.title)"' 2>/dev/null || true -} - -get_next_task_github() { - local args=(--repo "$GITHUB_REPO" --state open --limit 1 --json number,title) - [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") - - gh issue list "${args[@]}" \ - --jq '.[0] | "\(.number):\(.title)"' 2>/dev/null | cut -c1-50 || echo "" -} - -count_remaining_github() { - local args=(--repo "$GITHUB_REPO" --state open --json number) - [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") - - gh issue list "${args[@]}" \ - --jq 'length' 2>/dev/null || echo "0" -} - -count_completed_github() { - local args=(--repo "$GITHUB_REPO" --state closed --json number) - [[ -n "$GITHUB_LABEL" ]] && args+=(--label "$GITHUB_LABEL") - - gh issue list "${args[@]}" \ - --jq 'length' 2>/dev/null || echo "0" -} - -mark_task_complete_github() { - local task=$1 - # Extract issue number from "number:title" format - local issue_num="${task%%:*}" - gh issue close "$issue_num" --repo "$GITHUB_REPO" 2>/dev/null || true -} - -get_github_issue_body() { - local task=$1 - local issue_num="${task%%:*}" - gh issue view "$issue_num" --repo "$GITHUB_REPO" --json body --jq '.body' 2>/dev/null || echo "" -} - -# ============================================ -# UNIFIED TASK INTERFACE -# ============================================ - -get_next_task() { - case "$PRD_SOURCE" in - markdown) get_next_task_markdown ;; - yaml) get_next_task_yaml ;; - github) get_next_task_github ;; - esac -} - -get_all_tasks() { - case "$PRD_SOURCE" in - markdown) get_tasks_markdown ;; - yaml) get_tasks_yaml ;; - github) get_tasks_github ;; - esac -} - -count_remaining_tasks() { - case "$PRD_SOURCE" in - markdown) count_remaining_markdown ;; - yaml) count_remaining_yaml ;; - github) count_remaining_github ;; - esac -} - -count_completed_tasks() { - case "$PRD_SOURCE" in - markdown) count_completed_markdown ;; - yaml) count_completed_yaml ;; - github) count_completed_github ;; - esac -} - -mark_task_complete() { - local task=$1 - case "$PRD_SOURCE" in - markdown) mark_task_complete_markdown "$task" ;; - yaml) mark_task_complete_yaml "$task" ;; - github) mark_task_complete_github "$task" ;; - esac -} - -# ============================================ -# GIT BRANCH MANAGEMENT -# ============================================ - -create_task_branch() { - local task=$1 - local branch_name="ralphy/$(slugify "$task")" - - log_debug "Creating branch: $branch_name from $BASE_BRANCH" - - # Stash any changes (only pop if a new stash was created) - local stash_before stash_after stashed=false - stash_before=$(git stash list -1 --format='%gd %s' 2>/dev/null || true) - git stash push -m "ralphy-autostash" >/dev/null 2>&1 || true - stash_after=$(git stash list -1 --format='%gd %s' 2>/dev/null || true) - if [[ -n "$stash_after" ]] && [[ "$stash_after" != "$stash_before" ]] && [[ "$stash_after" == *"ralphy-autostash"* ]]; then - stashed=true - fi - - # Create and checkout new branch - git checkout "$BASE_BRANCH" 2>/dev/null || true - git pull origin "$BASE_BRANCH" 2>/dev/null || true - git checkout -b "$branch_name" 2>/dev/null || { - # Branch might already exist - git checkout "$branch_name" 2>/dev/null || true - } - - # Pop stash if we stashed - if [[ "$stashed" == true ]]; then - git stash pop >/dev/null 2>&1 || true - fi - - task_branches+=("$branch_name") - echo "$branch_name" -} - -create_pull_request() { - local branch=$1 - local task=$2 - local body="${3:-Automated PR created by Ralphy}" - - local draft_flag="" - [[ "$PR_DRAFT" == true ]] && draft_flag="--draft" - - log_info "Creating pull request for $branch..." - - # Push branch first - git push -u origin "$branch" 2>/dev/null || { - log_warn "Failed to push branch $branch" - return 1 - } - - # Create PR - local pr_url - pr_url=$(gh pr create \ - --base "$BASE_BRANCH" \ - --head "$branch" \ - --title "$task" \ - --body "$body" \ - $draft_flag 2>/dev/null) || { - log_warn "Failed to create PR for $branch" - return 1 - } - - log_success "PR created: $pr_url" - echo "$pr_url" -} - -return_to_base_branch() { - if [[ "$BRANCH_PER_TASK" == true ]]; then - git checkout "$BASE_BRANCH" 2>/dev/null || true - fi -} - -# ============================================ -# PROGRESS MONITOR -# ============================================ - -monitor_progress() { - local file=$1 - local task=$2 - local start_time - start_time=$(date +%s) - local spinstr='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' - local spin_idx=0 - - task="${task:0:40}" - - while true; do - local elapsed=$(($(date +%s) - start_time)) - local mins=$((elapsed / 60)) - local secs=$((elapsed % 60)) - - # Check latest output for step indicators - if [[ -f "$file" ]] && [[ -s "$file" ]]; then - local content - content=$(tail -c 5000 "$file" 2>/dev/null || true) - - if echo "$content" | grep -qE 'git commit|"command":"git commit'; then - current_step="Committing" - elif echo "$content" | grep -qE 'git add|"command":"git add'; then - current_step="Staging" - elif echo "$content" | grep -qE 'progress\.txt'; then - current_step="Logging" - elif echo "$content" | grep -qE 'PRD\.md|tasks\.yaml'; then - current_step="Updating PRD" - elif echo "$content" | grep -qE 'lint|eslint|biome|prettier'; then - current_step="Linting" - elif echo "$content" | grep -qE 'vitest|jest|bun test|npm test|pytest|go test'; then - current_step="Testing" - elif echo "$content" | grep -qE '\.test\.|\.spec\.|__tests__|_test\.go'; then - current_step="Writing tests" - elif echo "$content" | grep -qE '"tool":"[Ww]rite"|"tool":"[Ee]dit"|"name":"write"|"name":"edit"'; then - current_step="Implementing" - elif echo "$content" | grep -qE '"tool":"[Rr]ead"|"tool":"[Gg]lob"|"tool":"[Gg]rep"|"name":"read"|"name":"glob"|"name":"grep"'; then - current_step="Reading code" - fi - fi - - local spinner_char="${spinstr:$spin_idx:1}" - local step_color="" - - # Color-code steps - case "$current_step" in - "Thinking"|"Reading code") step_color="$CYAN" ;; - "Implementing"|"Writing tests") step_color="$MAGENTA" ;; - "Testing"|"Linting") step_color="$YELLOW" ;; - "Staging"|"Committing") step_color="$GREEN" ;; - *) step_color="$BLUE" ;; - esac - - # Use tput for cleaner line clearing - tput cr 2>/dev/null || printf "\r" - tput el 2>/dev/null || true - printf " %s ${step_color}%-16s${RESET} │ %s ${DIM}[%02d:%02d]${RESET}" "$spinner_char" "$current_step" "$task" "$mins" "$secs" - - spin_idx=$(( (spin_idx + 1) % ${#spinstr} )) - sleep 0.12 - done -} - -# ============================================ -# NOTIFICATION (Cross-platform) -# ============================================ - -notify_done() { - local message="${1:-Ralphy has completed all tasks!}" - - # macOS - if command -v afplay &>/dev/null; then - afplay /System/Library/Sounds/Glass.aiff 2>/dev/null & - fi - - # macOS notification - if command -v osascript &>/dev/null; then - osascript -e "display notification \"$message\" with title \"Ralphy\"" 2>/dev/null || true - fi - - # Linux (notify-send) - if command -v notify-send &>/dev/null; then - notify-send "Ralphy" "$message" 2>/dev/null || true - fi - - # Linux (paplay for sound) - if command -v paplay &>/dev/null; then - paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null & - fi - - # Windows (powershell) - if command -v powershell.exe &>/dev/null; then - powershell.exe -Command "[System.Media.SystemSounds]::Asterisk.Play()" 2>/dev/null || true - fi -} - -notify_error() { - local message="${1:-Ralphy encountered an error}" - - # macOS - if command -v osascript &>/dev/null; then - osascript -e "display notification \"$message\" with title \"Ralphy - Error\"" 2>/dev/null || true - fi - - # Linux - if command -v notify-send &>/dev/null; then - notify-send -u critical "Ralphy - Error" "$message" 2>/dev/null || true - fi -} - -# ============================================ -# PROMPT BUILDER -# ============================================ - -build_prompt() { - local task_override="${1:-}" - local prompt="" - - # Add .ralphy/ config if available (works with PRD mode too) - if [[ -d "$RALPHY_DIR" ]]; then - # Add project context - local context - context=$(load_project_context) - if [[ -n "$context" ]]; then - prompt+="## Project Context -$context - -" - fi - - # Add rules - local rules - rules=$(load_ralphy_rules) - if [[ -n "$rules" ]]; then - prompt+="## Rules (you MUST follow these) -$rules - -" - fi - - # Add boundaries - local never_touch - never_touch=$(load_ralphy_boundaries "never_touch") - if [[ -n "$never_touch" ]]; then - prompt+="## Boundaries - Do NOT modify these files: -$never_touch - -" - fi - fi - - # Add context based on PRD source - case "$PRD_SOURCE" in - markdown) - prompt="## PRD -$(cat "$PRD_FILE") - -## Progress -$(cat "$PROGRESS_FILE")" - ;; - yaml) - prompt="## Tasks -$(cat "$PRD_FILE") - -## Progress -$(cat "$PROGRESS_FILE")" - ;; - github) - # For GitHub issues, we include the issue body - local issue_body="" - if [[ -n "$task_override" ]]; then - issue_body=$(get_github_issue_body "$task_override") - fi - prompt="Task from GitHub Issue: $task_override - -Issue Description: -$issue_body - -## Progress -$(cat "$PROGRESS_FILE")" - ;; - esac - - prompt="$prompt -1. Find the highest-priority incomplete task and implement it." - - local step=2 - - if [[ "$SKIP_TESTS" == false ]]; then - prompt="$prompt -$step. Write tests for the feature. -$((step+1)). Run tests and ensure they pass before proceeding." - step=$((step+2)) - fi - - if [[ "$SKIP_LINT" == false ]]; then - prompt="$prompt -$step. Run linting and ensure it passes before proceeding." - step=$((step+1)) - fi - - # Adjust completion step based on PRD source - case "$PRD_SOURCE" in - markdown) - prompt="$prompt -$step. Update the PRD to mark the task as complete (change '- [ ]' to '- [x]')." - ;; - yaml) - prompt="$prompt -$step. Update ${PRD_FILE} to mark the task as completed (set completed: true)." - ;; - github) - prompt="$prompt -$step. The task will be marked complete automatically. Just note the completion in $PROGRESS_FILE." - ;; - esac - - step=$((step+1)) - - prompt="$prompt -$step. Append your progress to $PROGRESS_FILE. -$((step+1)). Commit your changes with a descriptive message. -ONLY WORK ON A SINGLE TASK." - - if [[ "$SKIP_TESTS" == false ]]; then - prompt="$prompt Do not proceed if tests fail." - fi - if [[ "$SKIP_LINT" == false ]]; then - prompt="$prompt Do not proceed if linting fails." - fi - - prompt="$prompt -If ALL tasks in the PRD are complete, output COMPLETE." - - echo "$prompt" -} - -# ============================================ -# AI ENGINE ABSTRACTION -# ============================================ - -run_ai_command() { - local prompt=$1 - local output_file=$2 - - case "$AI_ENGINE" in - opencode) - # OpenCode: use 'run' command with JSON format and permissive settings - OPENCODE_PERMISSION='{"*":"allow"}' opencode run \ - --format json \ - "$prompt" > "$output_file" 2>&1 & - ;; - cursor) - # Cursor agent: use --print for non-interactive, --force to allow all commands - agent --print --force \ - --output-format stream-json \ - "$prompt" > "$output_file" 2>&1 & - ;; - qwen) - # Qwen-Code: use CLI with JSON format and auto-approve tools - qwen --output-format stream-json \ - --approval-mode yolo \ - -p "$prompt" > "$output_file" 2>&1 & - ;; - droid) - # Droid: use exec with stream-json output and medium autonomy for development - droid exec --output-format stream-json \ - --auto medium \ - "$prompt" > "$output_file" 2>&1 & - ;; - codex) - CODEX_LAST_MESSAGE_FILE="${output_file}.last" - rm -f "$CODEX_LAST_MESSAGE_FILE" - codex exec --full-auto \ - --json \ - --output-last-message "$CODEX_LAST_MESSAGE_FILE" \ - "$prompt" > "$output_file" 2>&1 & - ;; - *) - # Claude Code: use existing approach - claude --dangerously-skip-permissions \ - --verbose \ - --output-format stream-json \ - -p "$prompt" > "$output_file" 2>&1 & - ;; - esac - - ai_pid=$! -} - -parse_ai_result() { - local result=$1 - local response="" - local input_tokens=0 - local output_tokens=0 - local actual_cost="0" - - case "$AI_ENGINE" in - opencode) - # OpenCode JSON format: uses step_finish for tokens and text events for response - local step_finish - step_finish=$(echo "$result" | grep '"type":"step_finish"' | tail -1 || echo "") - - if [[ -n "$step_finish" ]]; then - input_tokens=$(echo "$step_finish" | jq -r '.part.tokens.input // 0' 2>/dev/null || echo "0") - output_tokens=$(echo "$step_finish" | jq -r '.part.tokens.output // 0' 2>/dev/null || echo "0") - # OpenCode provides actual cost directly - actual_cost=$(echo "$step_finish" | jq -r '.part.cost // 0' 2>/dev/null || echo "0") - fi - - # Get text response from text events - response=$(echo "$result" | grep '"type":"text"' | jq -rs 'map(.part.text // "") | join("")' 2>/dev/null || echo "") - - # If no text found, indicate task completed - if [[ -z "$response" ]]; then - response="Task completed" - fi - ;; - cursor) - # Cursor agent: parse stream-json output - # Cursor doesn't provide token counts, but does provide duration_ms - - local result_line - result_line=$(echo "$result" | grep '"type":"result"' | tail -1) - - if [[ -n "$result_line" ]]; then - response=$(echo "$result_line" | jq -r '.result // "Task completed"' 2>/dev/null || echo "Task completed") - # Cursor provides duration instead of tokens - local duration_ms - duration_ms=$(echo "$result_line" | jq -r '.duration_ms // 0' 2>/dev/null || echo "0") - # Store duration in output_tokens field for now (we'll handle it specially) - # Use negative value as marker that this is duration, not tokens - if [[ "$duration_ms" =~ ^[0-9]+$ ]] && [[ "$duration_ms" -gt 0 ]]; then - # Encode duration: store as-is, we track separately - actual_cost="duration:$duration_ms" - fi - fi - - # Get response from assistant message if result is empty - if [[ -z "$response" ]] || [[ "$response" == "Task completed" ]]; then - local assistant_msg - assistant_msg=$(echo "$result" | grep '"type":"assistant"' | tail -1) - if [[ -n "$assistant_msg" ]]; then - response=$(echo "$assistant_msg" | jq -r '.message.content[0].text // .message.content // "Task completed"' 2>/dev/null || echo "Task completed") - fi - fi - - # Tokens remain 0 for Cursor (not available) - input_tokens=0 - output_tokens=0 - ;; - qwen) - # Qwen-Code stream-json parsing (similar to Claude Code) - local result_line - result_line=$(echo "$result" | grep '"type":"result"' | tail -1) - - if [[ -n "$result_line" ]]; then - response=$(echo "$result_line" | jq -r '.result // "No result text"' 2>/dev/null || echo "Could not parse result") - input_tokens=$(echo "$result_line" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0") - output_tokens=$(echo "$result_line" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0") - fi - - # Fallback when no response text was parsed, similar to OpenCode behavior - if [[ -z "$response" ]]; then - response="Task completed" - fi - ;; - droid) - # Droid stream-json parsing - # Look for completion event which has the final result - local completion_line - completion_line=$(echo "$result" | grep '"type":"completion"' | tail -1) - - if [[ -n "$completion_line" ]]; then - response=$(echo "$completion_line" | jq -r '.finalText // "Task completed"' 2>/dev/null || echo "Task completed") - # Droid provides duration_ms in completion event - local dur_ms - dur_ms=$(echo "$completion_line" | jq -r '.durationMs // 0' 2>/dev/null || echo "0") - if [[ "$dur_ms" =~ ^[0-9]+$ ]] && [[ "$dur_ms" -gt 0 ]]; then - # Store duration for tracking - actual_cost="duration:$dur_ms" - fi - fi - - # Tokens remain 0 for Droid (not exposed in exec mode) - input_tokens=0 - output_tokens=0 - ;; - codex) - if [[ -n "$CODEX_LAST_MESSAGE_FILE" ]] && [[ -f "$CODEX_LAST_MESSAGE_FILE" ]]; then - response=$(cat "$CODEX_LAST_MESSAGE_FILE" 2>/dev/null || echo "") - # Codex sometimes prefixes a generic completion line; drop it for readability. - response=$(printf '%s' "$response" | sed '1{/^Task completed successfully\.[[:space:]]*$/d;}') - fi - input_tokens=0 - output_tokens=0 - ;; - *) - # Claude Code stream-json parsing - local result_line - result_line=$(echo "$result" | grep '"type":"result"' | tail -1) - - if [[ -n "$result_line" ]]; then - response=$(echo "$result_line" | jq -r '.result // "No result text"' 2>/dev/null || echo "Could not parse result") - input_tokens=$(echo "$result_line" | jq -r '.usage.input_tokens // 0' 2>/dev/null || echo "0") - output_tokens=$(echo "$result_line" | jq -r '.usage.output_tokens // 0' 2>/dev/null || echo "0") - fi - ;; - esac - - # Sanitize token counts - [[ "$input_tokens" =~ ^[0-9]+$ ]] || input_tokens=0 - [[ "$output_tokens" =~ ^[0-9]+$ ]] || output_tokens=0 - - echo "$response" - echo "---TOKENS---" - echo "$input_tokens" - echo "$output_tokens" - echo "$actual_cost" -} - -check_for_errors() { - local result=$1 - - if echo "$result" | grep -q '"type":"error"'; then - local error_msg - error_msg=$(echo "$result" | grep '"type":"error"' | head -1 | jq -r '.error.message // .message // .' 2>/dev/null || echo "Unknown error") - echo "$error_msg" - return 1 - fi - - return 0 -} - -# ============================================ -# COST CALCULATION -# ============================================ - -calculate_cost() { - local input=$1 - local output=$2 - - if command -v bc &>/dev/null; then - echo "scale=4; ($input * 0.000003) + ($output * 0.000015)" | bc - else - echo "N/A" - fi -} - -# ============================================ -# SINGLE TASK EXECUTION -# ============================================ - -run_single_task() { - local task_name="${1:-}" - local task_num="${2:-$iteration}" - - retry_count=0 - - echo "" - echo "${BOLD}>>> Task $task_num${RESET}" - - local remaining completed - remaining=$(count_remaining_tasks | tr -d '[:space:]') - completed=$(count_completed_tasks | tr -d '[:space:]') - remaining=${remaining:-0} - completed=${completed:-0} - echo "${DIM} Completed: $completed | Remaining: $remaining${RESET}" - echo "--------------------------------------------" - - # Get current task for display - local current_task - if [[ -n "$task_name" ]]; then - current_task="$task_name" - else - current_task=$(get_next_task) - fi - - if [[ -z "$current_task" ]]; then - log_info "No more tasks found" - return 2 - fi - - current_step="Thinking" - - # Create branch if needed - local branch_name="" - if [[ "$BRANCH_PER_TASK" == true ]]; then - branch_name=$(create_task_branch "$current_task") - log_info "Working on branch: $branch_name" - fi - - # Temp file for AI output - tmpfile=$(mktemp) - - # Build the prompt - local prompt - prompt=$(build_prompt "$current_task") - - if [[ "$DRY_RUN" == true ]]; then - log_info "DRY RUN - Would execute:" - echo "${DIM}$prompt${RESET}" - rm -f "$tmpfile" - tmpfile="" - return_to_base_branch - return 0 - fi - - # Run with retry logic - while [[ $retry_count -lt $MAX_RETRIES ]]; do - # Start AI command - run_ai_command "$prompt" "$tmpfile" - - # Start progress monitor in background - monitor_progress "$tmpfile" "${current_task:0:40}" & - monitor_pid=$! - - # Wait for AI to finish - wait "$ai_pid" 2>/dev/null || true - - # Stop the monitor - kill "$monitor_pid" 2>/dev/null || true - wait "$monitor_pid" 2>/dev/null || true - monitor_pid="" - - # Show completion - tput cr 2>/dev/null || printf "\r" - tput el 2>/dev/null || true - - # Read result - local result - result=$(cat "$tmpfile" 2>/dev/null || echo "") - - # Check for empty response - if [[ -z "$result" ]]; then - ((retry_count++)) || true - if [[ $retry_count -lt $MAX_RETRIES ]]; then - log_warn "Empty response, retrying... ($retry_count/$MAX_RETRIES)" - sleep "$RETRY_DELAY" - fi - continue - fi - - # Check for errors - local error_msg - error_msg=$(check_for_errors "$result") - if [[ -n "$error_msg" ]]; then - ((retry_count++)) || true - if [[ $retry_count -lt $MAX_RETRIES ]]; then - log_warn "AI error: $error_msg" - log_warn "Retrying... ($retry_count/$MAX_RETRIES)" - sleep "$RETRY_DELAY" - fi - continue - fi - - # Parse result - local parsed - parsed=$(parse_ai_result "$result") - local response - response=$(echo "$parsed" | sed '/^---TOKENS---$/,$d') - local token_data - token_data=$(echo "$parsed" | sed -n '/^---TOKENS---$/,$p' | tail -3) - local input_tokens - input_tokens=$(echo "$token_data" | sed -n '1p') - local output_tokens - output_tokens=$(echo "$token_data" | sed -n '2p') - local actual_cost - actual_cost=$(echo "$token_data" | sed -n '3p') - - printf " ${GREEN}✓${RESET} %-16s │ %s\n" "Done" "${current_task:0:40}" - - if [[ -n "$response" ]]; then - echo "" - echo "$response" - fi - - # Sanitize values - [[ "$input_tokens" =~ ^[0-9]+$ ]] || input_tokens=0 - [[ "$output_tokens" =~ ^[0-9]+$ ]] || output_tokens=0 - - # Update totals - total_input_tokens=$((total_input_tokens + input_tokens)) - total_output_tokens=$((total_output_tokens + output_tokens)) - - # Track actual cost for OpenCode, or duration for Cursor - if [[ -n "$actual_cost" ]]; then - if [[ "$actual_cost" == duration:* ]]; then - # Cursor duration tracking - local dur_ms="${actual_cost#duration:}" - [[ "$dur_ms" =~ ^[0-9]+$ ]] && total_duration_ms=$((total_duration_ms + dur_ms)) - elif [[ "$actual_cost" != "0" ]] && command -v bc &>/dev/null; then - # OpenCode cost tracking - total_actual_cost=$(echo "scale=6; $total_actual_cost + $actual_cost" | bc 2>/dev/null || echo "$total_actual_cost") - fi - fi - - rm -f "$tmpfile" - tmpfile="" - if [[ "$AI_ENGINE" == "codex" ]] && [[ -n "$CODEX_LAST_MESSAGE_FILE" ]]; then - rm -f "$CODEX_LAST_MESSAGE_FILE" - CODEX_LAST_MESSAGE_FILE="" - fi - - # Mark task complete for GitHub issues (since AI can't do it) - if [[ "$PRD_SOURCE" == "github" ]]; then - mark_task_complete "$current_task" - fi - - # Create PR if requested - if [[ "$CREATE_PR" == true ]] && [[ -n "$branch_name" ]]; then - create_pull_request "$branch_name" "$current_task" "Automated implementation by Ralphy" - fi - - # Return to base branch - return_to_base_branch - - # Check for completion - verify by actually counting remaining tasks - local remaining_count - remaining_count=$(count_remaining_tasks | tr -d '[:space:]' | head -1) - remaining_count=${remaining_count:-0} - [[ "$remaining_count" =~ ^[0-9]+$ ]] || remaining_count=0 - - if [[ "$remaining_count" -eq 0 ]]; then - return 2 # All tasks actually complete - fi - - # AI might claim completion but tasks remain - continue anyway - if [[ "$result" == *"COMPLETE"* ]]; then - log_debug "AI claimed completion but $remaining_count tasks remain, continuing..." - fi - - return 0 - done - - return_to_base_branch - return 1 -} - -# ============================================ -# PARALLEL TASK EXECUTION -# ============================================ - -# Create an isolated worktree for a parallel agent -create_agent_worktree() { - local task_name="$1" - local agent_num="$2" - local branch_name="ralphy/agent-${agent_num}-$(slugify "$task_name")" - local worktree_dir="${WORKTREE_BASE}/agent-${agent_num}" - - # Run git commands from original directory - # All git output goes to stderr so it doesn't interfere with our return value - ( - cd "$ORIGINAL_DIR" || { echo "Failed to cd to $ORIGINAL_DIR" >&2; exit 1; } - - # Prune any stale worktrees first - git worktree prune >&2 - - # Delete branch if it exists (force) - git branch -D "$branch_name" >&2 2>/dev/null || true - - # Create worktree - git worktree add "$worktree_dir" "$ORIGINAL_BASE_BRANCH" >&2 || { - echo "Failed to create worktree $worktree_dir" >&2 - exit 1 - } - - # Create and checkout branch in the worktree - ( - cd "$worktree_dir" || exit 1 - git checkout -b "$branch_name" >&2 || { - echo "Failed to create branch $branch_name" >&2 - exit 1 - } - ) || exit 1 - ) || return 1 - - echo "$worktree_dir" -} - -# Run a single agent in parallel -run_parallel_agent() { - local agent_num="$1" - local task_name="$2" - local worktree_dir="$3" - - # Change to worktree directory - cd "$worktree_dir" || return 1 - - # Set up agent-specific state - local agent_iteration=0 - local agent_tmpfile="" - local agent_monitor_pid="" - local agent_ai_pid="" - - # Create agent-specific progress file - local agent_progress_file="$RALPHY_DIR/progress-agent-${agent_num}.txt" - echo "# Ralphy Agent $agent_num Progress Log" > "$agent_progress_file" - echo "" >> "$agent_progress_file" - - log_info "Agent $agent_num: Starting task '$task_name'" - - # Run single task with agent-specific settings - while true; do - ((agent_iteration++)) || true - - # Create temp file for this agent's AI output - agent_tmpfile=$(mktemp) - - # Build prompt for this specific task - local prompt - prompt=$(build_prompt "$task_name") - - # Replace progress file reference with agent-specific one - prompt=$(echo "$prompt" | sed "s/@$PROGRESS_FILE/@$agent_progress_file/") - - # Run AI command - run_ai_command "$prompt" "$agent_tmpfile" - - # Start progress monitor - monitor_progress "$agent_tmpfile" "${task_name:0:30} (A$agent_num)" & - agent_monitor_pid=$! - - # Wait for AI - wait "$agent_ai_pid" 2>/dev/null || true - - # Stop monitor - kill "$agent_monitor_pid" 2>/dev/null || true - wait "$agent_monitor_pid" 2>/dev/null || true - agent_monitor_pid="" - - # Show completion for this agent - tput cr 2>/dev/null || printf "\r" - tput el 2>/dev/null || true - printf " ${GREEN}✓${RESET} %-16s │ %s ${DIM}(Agent %d)${RESET}\n" "Done" "${task_name:0:30}" "$agent_num" - - # Read result - local result - result=$(cat "$agent_tmpfile" 2>/dev/null || echo "") - - # Parse result (similar to run_single_task) - if [[ -n "$result" ]]; then - local parsed - parsed=$(parse_ai_result "$result") - local response - response=$(echo "$parsed" | sed '/^---TOKENS---$/,$d') - - # Update global token counts (simplified - just add to totals) - local token_data - token_data=$(echo "$parsed" | sed -n '/^---TOKENS---$/,$p' | tail -3) - local input_tokens - input_tokens=$(echo "$token_data" | sed -n '1p') - local output_tokens - output_tokens=$(echo "$token_data" | sed -n '2p') - local actual_cost - actual_cost=$(echo "$token_data" | sed -n '3p') - - [[ "$input_tokens" =~ ^[0-9]+$ ]] && total_input_tokens=$((total_input_tokens + input_tokens)) - [[ "$output_tokens" =~ ^[0-9]+$ ]] && total_output_tokens=$((total_output_tokens + output_tokens)) - - if [[ -n "$actual_cost" ]]; then - if [[ "$actual_cost" == duration:* ]]; then - local dur_ms="${actual_cost#duration:}" - [[ "$dur_ms" =~ ^[0-9]+$ ]] && total_duration_ms=$((total_duration_ms + dur_ms)) - elif [[ "$actual_cost" != "0" ]] && command -v bc &>/dev/null; then - total_actual_cost=$(echo "scale=6; $total_actual_cost + $actual_cost" | bc 2>/dev/null || echo "$total_actual_cost") - fi - fi - fi - - rm -f "$agent_tmpfile" - agent_tmpfile="" - - # Check if task is complete (AI should mark it in the PRD file) - # For parallel execution, we assume the AI marks tasks complete appropriately - # We break after one successful run per agent - break - done - - # Merge changes back to integration branch - merge_to_integration "$worktree_dir" "$task_name" "$agent_num" - - log_info "Agent $agent_num: Completed task '$task_name'" -} - -# Merge agent worktree back to integration branch -merge_to_integration() { - local worktree_dir="$1" - local task_name="$2" - local agent_num="$3" - - local branch_name="ralphy/agent-${agent_num}-$(slugify "$task_name")" - local integration_branch="ralphy/integration-$(slugify "$task_name")" - - # Run git commands from original directory - ( - cd "$ORIGINAL_DIR" || exit 1 - - # Create integration branch if it doesn't exist - if ! git show-ref --verify --quiet "refs/heads/$integration_branch"; then - git checkout -b "$integration_branch" "$ORIGINAL_BASE_BRANCH" >&2 || { - echo "Failed to create integration branch $integration_branch" >&2 - exit 1 - } - integration_branches+=("$integration_branch") - else - git checkout "$integration_branch" >&2 || exit 1 - fi - - # Merge agent branch (allow unrelated histories) - git merge "$branch_name" --allow-unrelated-histories --no-edit >&2 || { - echo "Failed to merge $branch_name into $integration_branch" >&2 - # Try to resolve conflicts automatically - git merge --abort >&2 2>/dev/null || true - exit 1 - } - - # Push integration branch - git push -u origin "$integration_branch" >&2 2>/dev/null || true - ) || log_warn "Agent $agent_num: Failed to merge changes for '$task_name'" -} - -# Run parallel execution -run_parallel_tasks() { - # Set up worktree base - WORKTREE_BASE=$(mktemp -d) - ORIGINAL_DIR="$PWD" - ORIGINAL_BASE_BRANCH="$BASE_BRANCH" - - log_info "Using worktree base: $WORKTREE_BASE" - - # Get all tasks - local all_tasks - all_tasks=$(get_all_tasks) - - if [[ -z "$all_tasks" ]]; then - log_warn "No tasks found" - return - fi - - # Convert to array - local task_array=() - while IFS= read -r task; do - task_array+=("$task") - done <<< "$all_tasks" - - local total_tasks=${#task_array[@]} - log_info "Running $total_tasks tasks in parallel (max $MAX_PARALLEL concurrent)" - - local agent_num=0 - local active_pids=() - local completed_count=0 - - for task in "${task_array[@]}"; do - ((agent_num++)) || true - - # Create worktree for this agent - local worktree_dir - worktree_dir=$(create_agent_worktree "$task" "$agent_num") - - if [[ -z "$worktree_dir" ]]; then - log_error "Failed to create worktree for agent $agent_num" - continue - fi - - # Start agent in background - run_parallel_agent "$agent_num" "$task" "$worktree_dir" & - local pid=$! - parallel_pids+=("$pid") - active_pids+=("$pid") - - log_debug "Started agent $agent_num (PID $pid) for task: $task" - - # Wait if we've reached max parallel - while [[ ${#active_pids[@]} -ge $MAX_PARALLEL ]]; do - # Check which PIDs are still running - local still_active=() - for pid in "${active_pids[@]}"; do - if kill -0 "$pid" 2>/dev/null; then - still_active+=("$pid") - else - wait "$pid" 2>/dev/null || true - ((completed_count++)) || true - fi - done - active_pids=("${still_active[@]}") - - # Brief sleep to avoid busy waiting - sleep 0.5 - done - done - - # Wait for remaining agents - for pid in "${active_pids[@]}"; do - wait "$pid" 2>/dev/null || true - ((completed_count++)) || true - done - - log_success "All $completed_count parallel agents completed" - - # Create PRs for integration branches if requested - if [[ "$CREATE_PR" == true ]] && [[ -n "${integration_branches[*]+"${integration_branches[*]}"}" ]]; then - for branch in "${integration_branches[@]}"; do - create_pull_request "$branch" "Integration: ${branch#ralphy/integration-}" "Automated integration by Ralphy parallel execution" - done - fi - - # Cleanup worktrees - if [[ -d "$WORKTREE_BASE" ]]; then - for dir in "$WORKTREE_BASE"/agent-*; do - if [[ -d "$dir" ]]; then - git worktree remove "$dir" 2>/dev/null || true - fi - done - rm -rf "$WORKTREE_BASE" 2>/dev/null || true - fi -} - -# ============================================ -# SUMMARY & REPORTING -# ============================================ - -show_summary() { - local completed - completed=$(count_completed_tasks | tr -d '[:space:]') - completed=${completed:-0} - - echo "" - echo "${BOLD}============================================${RESET}" - echo "${BOLD}SUMMARY${RESET}" - echo "${BOLD}============================================${RESET}" - echo "Tasks completed: $completed" - - # Show token usage - if [[ $total_input_tokens -gt 0 ]] || [[ $total_output_tokens -gt 0 ]]; then - echo "Input tokens: $total_input_tokens" - echo "Output tokens: $total_output_tokens" - - if [[ -n "$total_actual_cost" ]] && [[ "$total_actual_cost" != "0" ]]; then - echo "Actual cost: $$total_actual_cost" - else - local estimated_cost - estimated_cost=$(calculate_cost "$total_input_tokens" "$total_output_tokens") - echo "Est. cost: $$estimated_cost" - fi - fi - - # Show duration if available - if [[ $total_duration_ms -gt 0 ]]; then - local total_seconds=$((total_duration_ms / 1000)) - local mins=$((total_seconds / 60)) - local secs=$((total_seconds % 60)) - echo "Duration: ${mins}m ${secs}s" - fi - - echo "" - echo "${GREEN}All tasks completed!${RESET}" - echo "" -} - -# ============================================ -# MAIN -# ============================================ - -main() { - parse_args "$@" - - # Handle --init mode - if [[ "$INIT_MODE" == true ]]; then - init_ralphy_config - exit 0 - fi - - # Handle --config mode - if [[ "$SHOW_CONFIG" == true ]]; then - show_ralphy_config - exit 0 - fi - - # Handle --add-rule - if [[ -n "$ADD_RULE" ]]; then - add_ralphy_rule "$ADD_RULE" - exit 0 - fi - - # Handle single-task (brownfield) mode - if [[ -n "$SINGLE_TASK" ]]; then - # Set up cleanup trap - trap cleanup EXIT - trap 'exit 130' INT TERM HUP - - # Check basic requirements (AI engine, git) - case "$AI_ENGINE" in - claude) command -v claude &>/dev/null || { log_error "Claude Code CLI not found"; exit 1; } ;; - opencode) command -v opencode &>/dev/null || { log_error "OpenCode CLI not found"; exit 1; } ;; - cursor) command -v agent &>/dev/null || { log_error "Cursor agent CLI not found"; exit 1; } ;; - codex) command -v codex &>/dev/null || { log_error "Codex CLI not found"; exit 1; } ;; - qwen) command -v qwen &>/dev/null || { log_error "Qwen-Code CLI not found"; exit 1; } ;; - droid) command -v droid &>/dev/null || { log_error "Factory Droid CLI not found"; exit 1; } ;; - esac - - if ! git rev-parse --git-dir >/dev/null 2>&1; then - log_error "Not a git repository" - exit 1 - fi - - # Show brownfield banner - echo "${BOLD}============================================${RESET}" - echo "${BOLD}Ralphy${RESET} - Single Task Mode" - local engine_display - case "$AI_ENGINE" in - opencode) engine_display="${CYAN}OpenCode${RESET}" ;; - cursor) engine_display="${YELLOW}Cursor Agent${RESET}" ;; - codex) engine_display="${BLUE}Codex${RESET}" ;; - qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; - droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; - *) engine_display="${MAGENTA}Claude Code${RESET}" ;; - esac - echo "Engine: $engine_display" - if [[ -d "$RALPHY_DIR" ]]; then - echo "Config: ${GREEN}$RALPHY_DIR/${RESET}" - else - echo "Config: ${DIM}none (run --init to configure)${RESET}" - fi - echo "${BOLD}============================================${RESET}" - - run_brownfield_task "$SINGLE_TASK" - exit $? - fi - - if [[ "$DRY_RUN" == true ]] && [[ "$MAX_ITERATIONS" -eq 0 ]]; then - MAX_ITERATIONS=1 - fi - - # Set up cleanup trap - trap cleanup EXIT - trap 'exit 130' INT TERM HUP - - # Check requirements - check_requirements - - # Show banner - echo "${BOLD}============================================${RESET}" - echo "${BOLD}Ralphy${RESET} - Running until PRD is complete" - local engine_display - case "$AI_ENGINE" in - opencode) engine_display="${CYAN}OpenCode${RESET}" ;; - cursor) engine_display="${YELLOW}Cursor Agent${RESET}" ;; - codex) engine_display="${BLUE}Codex${RESET}" ;; - qwen) engine_display="${GREEN}Qwen-Code${RESET}" ;; - droid) engine_display="${MAGENTA}Factory Droid${RESET}" ;; - *) engine_display="${MAGENTA}Claude Code${RESET}" ;; - esac - echo "Engine: $engine_display" - echo "Source: ${CYAN}$PRD_SOURCE${RESET} (${PRD_FILE:-$GITHUB_REPO})" - if [[ -d "$RALPHY_DIR" ]]; then - echo "Config: ${GREEN}$RALPHY_DIR/${RESET} (rules loaded)" - fi - - local mode_parts=() - [[ "$SKIP_TESTS" == true ]] && mode_parts+=("no-tests") - [[ "$SKIP_LINT" == true ]] && mode_parts+=("no-lint") - [[ "$DRY_RUN" == true ]] && mode_parts+=("dry-run") - [[ "$PARALLEL" == true ]] && mode_parts+=("parallel:$MAX_PARALLEL") - [[ "$BRANCH_PER_TASK" == true ]] && mode_parts+=("branch-per-task") - [[ "$CREATE_PR" == true ]] && mode_parts+=("create-pr") - [[ $MAX_ITERATIONS -gt 0 ]] && mode_parts+=("max:$MAX_ITERATIONS") - - if [[ ${#mode_parts[@]} -gt 0 ]]; then - echo "Mode: ${YELLOW}${mode_parts[*]}${RESET}" - fi - echo "${BOLD}============================================${RESET}" - - # Run in parallel or sequential mode - if [[ "$PARALLEL" == true ]]; then - run_parallel_tasks - show_summary - notify_done - exit 0 - fi - - # Sequential main loop - while true; do - ((iteration++)) || true - local result_code=0 - run_single_task "" "$iteration" || result_code=$? - - case $result_code in - 0) - # Success, continue - ;; - 1) - # Error, but continue to next task - log_warn "Task failed after $MAX_RETRIES attempts, continuing..." - ;; - 2) - # All tasks complete - show_summary - notify_done - exit 0 - ;; - esac - - # Check max iterations - if [[ $MAX_ITERATIONS -gt 0 ]] && [[ $iteration -ge $MAX_ITERATIONS ]]; then - log_warn "Reached max iterations ($MAX_ITERATIONS)" - show_summary - notify_done "Ralphy stopped after $MAX_ITERATIONS iterations" - exit 0 - fi - - # Small delay between iterations - sleep 1 - done -} - -# Run main -main "$@" diff --git a/server.log b/server.log deleted file mode 100644 index e69de29..0000000 diff --git a/supabase/migrations/20260120000000_add_anchor23_menu_structure.sql b/supabase/migrations/20260120000000_add_anchor23_menu_structure.sql new file mode 100644 index 0000000..23f8ef1 --- /dev/null +++ b/supabase/migrations/20260120000000_add_anchor23_menu_structure.sql @@ -0,0 +1,40 @@ +-- ============================================ +-- ADD ANCHOR 23 MENU STRUCTURE +-- Date: 20260120 +-- Description: Add columns to support complex service structure from Anchor 23 menu +-- ============================================ + +-- Add new columns for complex service structure +ALTER TABLE services ADD COLUMN IF NOT EXISTS subtitle VARCHAR(200); +ALTER TABLE services ADD COLUMN IF NOT EXISTS price_type VARCHAR(20) DEFAULT 'fixed'; +ALTER TABLE services ADD COLUMN IF NOT EXISTS duration_min INTEGER; +ALTER TABLE services ADD COLUMN IF NOT EXISTS duration_max INTEGER; +ALTER TABLE services ADD COLUMN IF NOT EXISTS requires_prerequisite BOOLEAN DEFAULT false; +ALTER TABLE services ADD COLUMN IF NOT EXISTS prerequisite_details JSONB; +ALTER TABLE services ADD COLUMN IF NOT EXISTS membership_benefits JSONB; + +-- Update existing duration_minutes to duration_max for backward compatibility +-- This ensures existing services still work while new services can use ranges +UPDATE services SET duration_max = duration_minutes WHERE duration_max IS NULL AND duration_minutes IS NOT NULL; + +-- Add check constraints for new fields +ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_price_type + CHECK (price_type IN ('fixed', 'starting_at')); + +ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_duration_range + CHECK (duration_min IS NULL OR duration_max IS NULL OR duration_min <= duration_max); + +ALTER TABLE services ADD CONSTRAINT IF NOT EXISTS check_duration_not_null + CHECK ( + (duration_min IS NOT NULL AND duration_max IS NOT NULL) OR + (duration_min IS NULL AND duration_max IS NOT NULL) + ); + +-- Add comments for documentation +COMMENT ON COLUMN services.subtitle IS 'Optional subtitle displayed under service name'; +COMMENT ON COLUMN services.price_type IS 'fixed or starting_at pricing type'; +COMMENT ON COLUMN services.duration_min IS 'Minimum duration in minutes for ranged services'; +COMMENT ON COLUMN services.duration_max IS 'Maximum duration in minutes for ranged services'; +COMMENT ON COLUMN services.requires_prerequisite IS 'Whether service requires prerequisite service'; +COMMENT ON COLUMN services.prerequisite_details IS 'JSON details about prerequisite requirements'; +COMMENT ON COLUMN services.membership_benefits IS 'JSON details about member-specific benefits'; \ No newline at end of file diff --git a/supabase/migrations/20260121000000_fix_staff_availability_function_calls.sql b/supabase/migrations/20260121000000_fix_staff_availability_function_calls.sql new file mode 100644 index 0000000..04f153d --- /dev/null +++ b/supabase/migrations/20260121000000_fix_staff_availability_function_calls.sql @@ -0,0 +1,85 @@ +-- ============================================ +-- FIX: Correct function calls in check_staff_availability +-- Date: 2026-01-21 +-- Description: Fix parameter issues in check_staff_availability function calls +-- ============================================ + +-- Drop and recreate check_staff_availability with correct function calls +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_location_timezone TEXT; + 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 s.*, l.timezone INTO v_staff, v_location_timezone + FROM staff s + JOIN locations l ON s.location_id = l.id + WHERE s.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 (with correct parameters) + v_has_work_conflict := NOT check_staff_work_hours(p_staff_id, p_start_time_utc, p_end_time_utc, v_location_timezone); + 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. 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; + +-- Grant execute permission +GRANT EXECUTE ON FUNCTION check_staff_availability TO authenticated, anon, service_role; + +COMMENT ON FUNCTION check_staff_availability IS 'Enhanced availability check including work hours, bookings, manual blocks, and Google Calendar sync with corrected function calls'; \ No newline at end of file diff --git a/supabase/migrations/20260121010000_staff_services_management.sql b/supabase/migrations/20260121010000_staff_services_management.sql new file mode 100644 index 0000000..f4dec7d --- /dev/null +++ b/supabase/migrations/20260121010000_staff_services_management.sql @@ -0,0 +1,75 @@ +-- ============================================ +-- STAFF SERVICES MANAGEMENT +-- Date: 2026-01-21 +-- Description: Add staff_services table and proficiency system +-- ============================================ + +-- Create staff_services table +CREATE TABLE staff_services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + proficiency_level INTEGER CHECK (proficiency_level >= 1 AND proficiency_level <= 5) DEFAULT 3, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(staff_id, service_id) +); + +-- Add indexes for performance +CREATE INDEX idx_staff_services_staff_id ON staff_services(staff_id); +CREATE INDEX idx_staff_services_service_id ON staff_services(service_id); +CREATE INDEX idx_staff_services_active ON staff_services(is_active); + +-- Add RLS policies +ALTER TABLE staff_services ENABLE ROW LEVEL SECURITY; + +-- Policy: Staff can view their own services +CREATE POLICY "Staff can view own services" +ON staff_services +FOR SELECT +USING ( + auth.uid()::text = ( + SELECT user_id::text FROM staff WHERE id = staff_id + ) +); + +-- Policy: Managers and admins can view all staff services +CREATE POLICY "Managers and admins can view all staff services" +ON staff_services +FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM staff s + WHERE s.user_id::text = auth.uid()::text + AND s.role IN ('manager', 'admin') + ) +); + +-- Policy: Managers and admins can manage staff services +CREATE POLICY "Managers and admins can manage staff services" +ON staff_services +FOR ALL +USING ( + EXISTS ( + SELECT 1 FROM staff s + WHERE s.user_id::text = auth.uid()::text + AND s.role IN ('manager', 'admin') + ) +); + +-- Add audit columns to bookings for tracking auto-assignment and invitations +ALTER TABLE bookings ADD COLUMN IF NOT EXISTS invitation_code_used TEXT; +ALTER TABLE bookings ADD COLUMN IF NOT EXISTS auto_assigned BOOLEAN DEFAULT false; +ALTER TABLE bookings ADD COLUMN IF NOT EXISTS assigned_by UUID REFERENCES staff(id); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_bookings_invitation_code ON bookings(invitation_code_used); +CREATE INDEX IF NOT EXISTS idx_bookings_auto_assigned ON bookings(auto_assigned); + +-- Grant permissions +GRANT SELECT, INSERT, UPDATE, DELETE ON staff_services TO authenticated; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO authenticated; + +COMMENT ON TABLE staff_services IS 'Tracks which services each staff member can perform and their proficiency level'; +COMMENT ON COLUMN staff_services.proficiency_level IS '1=Beginner, 2=Intermediate, 3=Competent, 4=Proficient, 5=Expert'; \ No newline at end of file