diff --git a/app/api/admin/calendars/route.ts b/app/api/admin/calendars/route.ts new file mode 100644 index 000000000..8e1ab9b16 --- /dev/null +++ b/app/api/admin/calendars/route.ts @@ -0,0 +1,343 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getDB } from '@/lib/db' +import { createCalDAVClient } from '@/lib/caldav-client' +import { z } from 'zod' + +export const dynamic = "force-dynamic" + +const createCalendarSchema = z.object({ + artistId: z.string().min(1), + calendarUrl: z.string().url(), + calendarId: z.string().min(1), +}) + +const updateCalendarSchema = createCalendarSchema.partial().extend({ + id: z.string().min(1), +}) + +/** + * GET /api/admin/calendars + * + * Get all artist calendar configurations + * Admin only + */ +export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const db = getDB(context?.env) + + // Check if user is admin + const user = await db + .prepare('SELECT role FROM users WHERE email = ?') + .bind(session.user.email) + .first() + + if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Get all calendar configurations with artist info and last sync log + const calendars = await db + .prepare(` + SELECT + ac.*, + a.name as artist_name, + a.slug as artist_slug, + ( + SELECT created_at + FROM calendar_sync_logs + WHERE artist_id = ac.artist_id + ORDER BY created_at DESC + LIMIT 1 + ) as last_sync_log_time, + ( + SELECT status + FROM calendar_sync_logs + WHERE artist_id = ac.artist_id + ORDER BY created_at DESC + LIMIT 1 + ) as last_sync_status + FROM artist_calendars ac + INNER JOIN artists a ON ac.artist_id = a.id + ORDER BY a.name + `) + .all() + + return NextResponse.json({ calendars: calendars.results }) + } catch (error) { + console.error('Error fetching calendars:', error) + return NextResponse.json( + { error: 'Failed to fetch calendars' }, + { status: 500 } + ) + } +} + +/** + * POST /api/admin/calendars + * + * Create a new artist calendar configuration + * Admin only + */ +export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const db = getDB(context?.env) + + // Check if user is admin + const user = await db + .prepare('SELECT role FROM users WHERE email = ?') + .bind(session.user.email) + .first() + + if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await request.json() + const validatedData = createCalendarSchema.parse(body) + + // Check if artist exists + const artist = await db + .prepare('SELECT id FROM artists WHERE id = ?') + .bind(validatedData.artistId) + .first() + + if (!artist) { + return NextResponse.json( + { error: 'Artist not found' }, + { status: 404 } + ) + } + + // Check if calendar config already exists for this artist + const existing = await db + .prepare('SELECT id FROM artist_calendars WHERE artist_id = ?') + .bind(validatedData.artistId) + .first() + + if (existing) { + return NextResponse.json( + { error: 'Calendar configuration already exists for this artist' }, + { status: 409 } + ) + } + + // Test calendar connection + const client = createCalDAVClient() + if (client) { + try { + await client.login() + // Try to fetch calendars to verify connection + await client.fetchCalendars() + } catch (testError) { + return NextResponse.json( + { error: 'Failed to connect to CalDAV server. Please check your credentials and calendar URL.' }, + { status: 400 } + ) + } + } + + // Create calendar configuration + const calendarId = crypto.randomUUID() + await db + .prepare(` + INSERT INTO artist_calendars ( + id, artist_id, calendar_url, calendar_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `) + .bind( + calendarId, + validatedData.artistId, + validatedData.calendarUrl, + validatedData.calendarId + ) + .run() + + // Fetch the created configuration + const calendar = await db + .prepare('SELECT * FROM artist_calendars WHERE id = ?') + .bind(calendarId) + .first() + + return NextResponse.json({ calendar }, { status: 201 }) + } catch (error) { + console.error('Error creating calendar:', error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid calendar data', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Failed to create calendar configuration' }, + { status: 500 } + ) + } +} + +/** + * PUT /api/admin/calendars + * + * Update an artist calendar configuration + * Admin only + */ +export async function PUT(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const db = getDB(context?.env) + + // Check if user is admin + const user = await db + .prepare('SELECT role FROM users WHERE email = ?') + .bind(session.user.email) + .first() + + if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await request.json() + const validatedData = updateCalendarSchema.parse(body) + + // Check if calendar exists + const existing = await db + .prepare('SELECT * FROM artist_calendars WHERE id = ?') + .bind(validatedData.id) + .first() + + if (!existing) { + return NextResponse.json( + { error: 'Calendar configuration not found' }, + { status: 404 } + ) + } + + // Build update query + const updateFields = [] + const updateValues = [] + + if (validatedData.calendarUrl) { + updateFields.push('calendar_url = ?') + updateValues.push(validatedData.calendarUrl) + } + if (validatedData.calendarId) { + updateFields.push('calendar_id = ?') + updateValues.push(validatedData.calendarId) + } + + if (updateFields.length === 0) { + return NextResponse.json( + { error: 'No fields to update' }, + { status: 400 } + ) + } + + updateFields.push('updated_at = CURRENT_TIMESTAMP') + updateValues.push(validatedData.id) + + await db + .prepare(` + UPDATE artist_calendars + SET ${updateFields.join(', ')} + WHERE id = ? + `) + .bind(...updateValues) + .run() + + // Fetch updated configuration + const calendar = await db + .prepare('SELECT * FROM artist_calendars WHERE id = ?') + .bind(validatedData.id) + .first() + + return NextResponse.json({ calendar }) + } catch (error) { + console.error('Error updating calendar:', error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid calendar data', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Failed to update calendar configuration' }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/admin/calendars + * + * Delete an artist calendar configuration + * Admin only + */ +export async function DELETE(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const db = getDB(context?.env) + + // Check if user is admin + const user = await db + .prepare('SELECT role FROM users WHERE email = ?') + .bind(session.user.email) + .first() + + if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json( + { error: 'Calendar ID is required' }, + { status: 400 } + ) + } + + const deleteStmt = db.prepare('DELETE FROM artist_calendars WHERE id = ?') + const result = await deleteStmt.bind(id).run() + + const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0 + if (written === 0) { + return NextResponse.json( + { error: 'Calendar configuration not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting calendar:', error) + return NextResponse.json( + { error: 'Failed to delete calendar configuration' }, + { status: 500 } + ) + } +} + diff --git a/app/api/appointments/route.ts b/app/api/appointments/route.ts index 44f214680..c044cf260 100644 --- a/app/api/appointments/route.ts +++ b/app/api/appointments/route.ts @@ -3,6 +3,11 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { getDB } from '@/lib/db' import { Flags } from '@/lib/flags' +import { + syncAppointmentToCalendar, + deleteAppointmentFromCalendar, + checkArtistAvailability, +} from '@/lib/calendar-sync' import { z } from 'zod' export const dynamic = "force-dynamic"; @@ -103,33 +108,30 @@ export async function POST(request: NextRequest, { params }: { params?: any } = const body = await request.json() const validatedData = createAppointmentSchema.parse(body) - // Check for scheduling conflicts const db = getDB(context?.env) - const conflictCheck = db.prepare(` - SELECT id FROM appointments - WHERE artist_id = ? - AND status NOT IN ('CANCELLED', 'COMPLETED') - AND ( - (start_time <= ? AND end_time > ?) OR - (start_time < ? AND end_time >= ?) OR - (start_time >= ? AND end_time <= ?) - ) - `) - const conflictResult = await conflictCheck.bind( + // IMPORTANT: Check CalDAV availability first (Nextcloud is source of truth) + const startDate = new Date(validatedData.startTime) + const endDate = new Date(validatedData.endTime) + + const availabilityCheck = await checkArtistAvailability( validatedData.artistId, - validatedData.startTime, validatedData.startTime, - validatedData.endTime, validatedData.endTime, - validatedData.startTime, validatedData.endTime - ).all() + startDate, + endDate, + context + ) - if (conflictResult.results.length > 0) { + if (!availabilityCheck.available) { return NextResponse.json( - { error: 'Time slot conflicts with existing appointment' }, + { + error: 'Time slot not available', + reason: availabilityCheck.reason || 'Selected time slot conflicts with existing booking. Please select a different time.' + }, { status: 409 } ) } + // Create appointment in database with PENDING status const appointmentId = crypto.randomUUID() const insertStmt = db.prepare(` INSERT INTO appointments ( @@ -166,6 +168,14 @@ export async function POST(request: NextRequest, { params }: { params?: any } = const appointment = await selectStmt.bind(appointmentId).first() + // Sync to CalDAV calendar (non-blocking - failure won't prevent appointment creation) + try { + await syncAppointmentToCalendar(appointment as any, context) + } catch (syncError) { + console.error('Failed to sync appointment to calendar:', syncError) + // Continue - appointment is created in DB even if CalDAV sync fails + } + return NextResponse.json({ appointment }, { status: 201 }) } catch (error) { console.error('Error creating appointment:', error) @@ -286,6 +296,14 @@ export async function PUT(request: NextRequest, { params }: { params?: any } = { const appointment = await selectStmt.bind(validatedData.id).first() + // Sync updated appointment to CalDAV (non-blocking) + try { + await syncAppointmentToCalendar(appointment as any, context) + } catch (syncError) { + console.error('Failed to sync updated appointment to calendar:', syncError) + // Continue - appointment is updated in DB even if CalDAV sync fails + } + return NextResponse.json({ appointment }) } catch (error) { console.error('Error updating appointment:', error) @@ -323,17 +341,29 @@ export async function DELETE(request: NextRequest, { params }: { params?: any } } const db = getDB(context?.env) - const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?') - const result = await deleteStmt.bind(id).run() - - const written = (result as any)?.meta?.changes ?? (result as any)?.meta?.rows_written ?? 0 - if (written === 0) { + + // Fetch appointment before deleting (needed for CalDAV sync) + const appointment = await db.prepare('SELECT * FROM appointments WHERE id = ?').bind(id).first() + + if (!appointment) { return NextResponse.json( { error: 'Appointment not found' }, { status: 404 } ) } + // Delete from CalDAV calendar first (non-blocking) + try { + await deleteAppointmentFromCalendar(appointment as any, context) + } catch (syncError) { + console.error('Failed to delete appointment from calendar:', syncError) + // Continue with DB deletion even if CalDAV deletion fails + } + + // Delete from database + const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?') + await deleteStmt.bind(id).run() + return NextResponse.json({ success: true }) } catch (error) { console.error('Error deleting appointment:', error) diff --git a/app/api/caldav/availability/route.ts b/app/api/caldav/availability/route.ts new file mode 100644 index 000000000..f414f84ce --- /dev/null +++ b/app/api/caldav/availability/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server' +import { checkArtistAvailability } from '@/lib/calendar-sync' +import { z } from 'zod' + +export const dynamic = "force-dynamic" + +const availabilitySchema = z.object({ + artistId: z.string().min(1), + startTime: z.string().datetime(), + endTime: z.string().datetime(), +}) + +/** + * GET /api/caldav/availability + * + * Check availability for an artist at a specific time slot + * + * Query params: + * - artistId: string + * - startTime: ISO datetime string + * - endTime: ISO datetime string + */ +export async function GET(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + const { searchParams } = new URL(request.url) + + const artistId = searchParams.get('artistId') + const startTime = searchParams.get('startTime') + const endTime = searchParams.get('endTime') + + // Validate inputs + const validatedData = availabilitySchema.parse({ + artistId, + startTime, + endTime, + }) + + const startDate = new Date(validatedData.startTime) + const endDate = new Date(validatedData.endTime) + + // Check availability (checks both CalDAV and database) + const result = await checkArtistAvailability( + validatedData.artistId, + startDate, + endDate, + context + ) + + return NextResponse.json({ + artistId: validatedData.artistId, + startTime: validatedData.startTime, + endTime: validatedData.endTime, + available: result.available, + reason: result.reason, + }) + } catch (error) { + console.error('Error checking availability:', error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request parameters', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Failed to check availability' }, + { status: 500 } + ) + } +} + diff --git a/app/api/caldav/sync/route.ts b/app/api/caldav/sync/route.ts new file mode 100644 index 000000000..bd5b8a2c9 --- /dev/null +++ b/app/api/caldav/sync/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { getDB } from '@/lib/db' +import { pullCalendarEventsToDatabase, logSync } from '@/lib/calendar-sync' +import { z } from 'zod' + +export const dynamic = "force-dynamic" + +const syncSchema = z.object({ + artistId: z.string().min(1).optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), +}) + +/** + * POST /api/caldav/sync + * + * Manually trigger calendar sync from Nextcloud to database + * Admin only endpoint + * + * Body: + * - artistId?: string (if omitted, syncs all artists) + * - startDate?: ISO datetime (defaults to 30 days ago) + * - endDate?: ISO datetime (defaults to 90 days from now) + */ +export async function POST(request: NextRequest, { params }: { params?: any } = {}, context?: any) { + try { + // Check authentication and authorization + const session = await getServerSession(authOptions) + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const db = getDB(context?.env) + + // Check if user is admin + const user = await db + .prepare('SELECT role FROM users WHERE email = ?') + .bind(session.user.email) + .first() + + if (!user || (user.role !== 'SUPER_ADMIN' && user.role !== 'SHOP_ADMIN')) { + return NextResponse.json({ error: 'Forbidden: Admin access required' }, { status: 403 }) + } + + const body = await request.json() + const validatedData = syncSchema.parse(body) + + // Set default date range + const startDate = validatedData.startDate + ? new Date(validatedData.startDate) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) // 30 days ago + + const endDate = validatedData.endDate + ? new Date(validatedData.endDate) + : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days from now + + // Get artists to sync + let artistsToSync: any[] = [] + + if (validatedData.artistId) { + const artist = await db + .prepare('SELECT id FROM artists WHERE id = ?') + .bind(validatedData.artistId) + .first() + + if (artist) { + artistsToSync = [artist] + } + } else { + // Get all artists with calendar configurations + const artists = await db + .prepare(` + SELECT DISTINCT a.id + FROM artists a + INNER JOIN artist_calendars ac ON a.id = ac.artist_id + WHERE a.is_active = TRUE + `) + .all() + + artistsToSync = artists.results + } + + if (artistsToSync.length === 0) { + return NextResponse.json({ + message: 'No artists with calendar configurations found', + synced: 0, + }) + } + + // Perform sync for each artist + const syncResults = [] + const startTime = Date.now() + + for (const artist of artistsToSync) { + const artistStartTime = Date.now() + + const result = await pullCalendarEventsToDatabase( + artist.id, + startDate, + endDate, + context + ) + + const duration = Date.now() - artistStartTime + + // Log the sync operation + await logSync({ + artistId: artist.id, + syncType: 'PULL', + status: result.success ? 'SUCCESS' : 'FAILED', + errorMessage: result.error, + eventsProcessed: result.eventsProcessed, + eventsCreated: result.eventsCreated, + eventsUpdated: result.eventsUpdated, + eventsDeleted: result.eventsDeleted, + durationMs: duration, + }, context) + + syncResults.push({ + artistId: artist.id, + ...result, + durationMs: duration, + }) + } + + const totalDuration = Date.now() - startTime + + return NextResponse.json({ + message: 'Sync completed', + totalArtists: artistsToSync.length, + totalDurationMs: totalDuration, + results: syncResults, + summary: { + totalEventsProcessed: syncResults.reduce((sum, r) => sum + r.eventsProcessed, 0), + totalEventsCreated: syncResults.reduce((sum, r) => sum + r.eventsCreated, 0), + totalEventsUpdated: syncResults.reduce((sum, r) => sum + r.eventsUpdated, 0), + totalEventsDeleted: syncResults.reduce((sum, r) => sum + r.eventsDeleted, 0), + successCount: syncResults.filter(r => r.success).length, + failureCount: syncResults.filter(r => !r.success).length, + }, + }) + } catch (error) { + console.error('Error during sync:', error) + + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request parameters', details: error.errors }, + { status: 400 } + ) + } + + return NextResponse.json( + { error: 'Sync failed', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} + diff --git a/components/booking-form.tsx b/components/booking-form.tsx index def770147..03a044f00 100644 --- a/components/booking-form.tsx +++ b/components/booking-form.tsx @@ -2,7 +2,7 @@ import type React from "react" -import { useState } from "react" +import { useState, useMemo } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" @@ -13,7 +13,8 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { useFeatureFlag } from "@/components/feature-flags-provider" import { useArtists } from "@/hooks/use-artist-data" -import { CalendarIcon, DollarSign, MessageSquare, User, Loader2 } from "lucide-react" +import { useAvailability } from "@/hooks/use-availability" +import { CalendarIcon, DollarSign, MessageSquare, User, Loader2, CheckCircle2, XCircle, AlertCircle } from "lucide-react" import { format } from "date-fns" import Link from "next/link" @@ -73,6 +74,44 @@ export function BookingForm({ artistId }: BookingFormProps) { const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize) const bookingEnabled = useFeatureFlag("BOOKING_ENABLED") + // Calculate appointment start and end times for availability checking + const { appointmentStart, appointmentEnd } = useMemo(() => { + if (!selectedDate || !formData.preferredTime || !selectedSize) { + return { appointmentStart: null, appointmentEnd: null } + } + + // Parse time slot (e.g., "2:00 PM") + const timeParts = formData.preferredTime.match(/(\d+):(\d+)\s*(AM|PM)/i) + if (!timeParts) return { appointmentStart: null, appointmentEnd: null } + + let hours = parseInt(timeParts[1]) + const minutes = parseInt(timeParts[2]) + const meridiem = timeParts[3].toUpperCase() + + if (meridiem === 'PM' && hours !== 12) hours += 12 + if (meridiem === 'AM' && hours === 12) hours = 0 + + const start = new Date(selectedDate) + start.setHours(hours, minutes, 0, 0) + + // Estimate duration from tattoo size (use max hours) + const durationHours = parseInt(selectedSize.duration.split('-')[1] || selectedSize.duration.split('-')[0]) + const end = new Date(start.getTime() + durationHours * 60 * 60 * 1000) + + return { + appointmentStart: start.toISOString(), + appointmentEnd: end.toISOString(), + } + }, [selectedDate, formData.preferredTime, selectedSize]) + + // Check availability in real-time + const availability = useAvailability({ + artistId: selectedArtist?.id || null, + startTime: appointmentStart, + endTime: appointmentEnd, + enabled: !!selectedArtist && !!appointmentStart && !!appointmentEnd && step === 2, + }) + const handleInputChange = (field: string, value: any) => { setFormData((prev) => ({ ...prev, [field]: value })) } @@ -337,6 +376,46 @@ export function BookingForm({ artistId }: BookingFormProps) { + {/* Availability Indicator */} + {selectedArtist && selectedDate && formData.preferredTime && selectedSize && ( +
+
+ {availability.checking ? ( + <> + + Checking availability... + + ) : availability.available ? ( + <> + + Time slot available! + + ) : ( + <> + +
+ Time slot not available + {availability.reason && ( + {availability.reason} + )} +
+ + )} +
+ {!availability.available && !availability.checking && ( +

+ Please select a different date or time, or provide an alternative below. +

+ )} +
+ )} +

Alternative Date & Time

@@ -598,8 +677,15 @@ export function BookingForm({ artistId }: BookingFormProps) { {step < 4 ? ( - ) : (