united-tattoo/lib/calendar-sync.ts
Nicholai a77f62f949 feat: implement CalDAV Nextcloud bidirectional calendar integration
Adds complete CalDAV integration for syncing appointments between the web app
and Nextcloud calendars with real-time availability checking and conflict resolution.

Core Features:
- Bidirectional sync: Web ↔ Nextcloud calendars
- Real-time availability checking with instant user feedback
- Conflict detection (Nextcloud is source of truth)
- Pending request workflow with 'REQUEST:' prefix for unconfirmed appointments
- Hard time blocking - any calendar event blocks booking slots
- Graceful degradation when CalDAV unavailable

New Dependencies:
- tsdav@^2.0.4 - TypeScript CalDAV client
- ical.js@^1.5.0 - iCalendar format parser/generator

Database Changes:
- New table: artist_calendars (stores calendar configuration per artist)
- New table: calendar_sync_logs (tracks all sync operations)
- Added caldav_uid and caldav_etag columns to appointments table
- Migration: sql/migrations/20250109_add_caldav_support.sql

New Services:
- lib/caldav-client.ts - Core CalDAV operations and iCalendar conversion
- lib/calendar-sync.ts - Bidirectional sync logic with error handling

New API Endpoints:
- GET /api/caldav/availability - Real-time availability checking
- POST /api/caldav/sync - Manual sync trigger (admin only)
- GET/POST/PUT/DELETE /api/admin/calendars - Calendar configuration CRUD

Updated Components:
- app/api/appointments/route.ts - Integrated CalDAV sync on CRUD operations
- components/booking-form.tsx - Added real-time availability indicator
- hooks/use-availability.ts - Custom hook for debounced availability checking

Documentation:
- docs/CALDAV-SETUP.md - Complete setup guide with troubleshooting
- docs/CALDAV-IMPLEMENTATION-SUMMARY.md - Technical implementation overview

Pending Tasks (for future PRs):
- Admin dashboard UI for calendar management
- Background sync worker (Cloudflare Workers cron)
- Unit and integration tests

Tested with local database migration and linting checks passed.
2025-10-08 20:44:17 -06:00

459 lines
12 KiB
TypeScript

/**
* Calendar Sync Service
*
* Handles bidirectional synchronization between database appointments
* and Nextcloud CalDAV calendars.
*/
import type { DAVClient } from 'tsdav'
import { getDB } from './db'
import {
createCalDAVClient,
createOrUpdateCalendarEvent,
deleteCalendarEvent,
fetchCalendarEvents,
} from './caldav-client'
import type { Appointment, CalendarSyncLog } from '@/types/database'
interface SyncResult {
success: boolean
error?: string
eventsProcessed: number
eventsCreated: number
eventsUpdated: number
eventsDeleted: number
}
/**
* Sync a single appointment to CalDAV calendar
* Called when appointment is created/updated via web app
*/
export async function syncAppointmentToCalendar(
appointment: Appointment,
context?: any
): Promise<{ uid: string; etag?: string } | null> {
const client = createCalDAVClient()
if (!client) {
console.warn('CalDAV not configured, skipping sync')
return null
}
try {
const db = getDB(context?.env)
// Get artist calendar configuration
const calendarConfig = await db
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
.bind(appointment.artistId)
.first()
if (!calendarConfig) {
console.warn(`No calendar configured for artist ${appointment.artistId}`)
return null
}
// Get artist and client names
const artist = await db
.prepare('SELECT name FROM artists WHERE id = ?')
.bind(appointment.artistId)
.first()
const client_user = await db
.prepare('SELECT name FROM users WHERE id = ?')
.bind(appointment.clientId)
.first()
const artistName = artist?.name || 'Unknown Artist'
const clientName = client_user?.name || 'Unknown Client'
// Create or update event in CalDAV
const result = await createOrUpdateCalendarEvent(
client,
calendarConfig.calendar_url,
appointment,
artistName,
clientName,
appointment.caldav_etag || undefined
)
if (result) {
// Update appointment with CalDAV UID and ETag
await db
.prepare('UPDATE appointments SET caldav_uid = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.bind(result.uid, result.etag || null, appointment.id)
.run()
return { uid: result.uid, etag: result.etag }
}
return null
} catch (error) {
console.error('Error syncing appointment to calendar:', error)
throw error
}
}
/**
* Delete appointment from CalDAV calendar
* Called when appointment is cancelled or deleted
*/
export async function deleteAppointmentFromCalendar(
appointment: Appointment,
context?: any
): Promise<boolean> {
const client = createCalDAVClient()
if (!client) {
console.warn('CalDAV not configured, skipping delete')
return false
}
try {
const db = getDB(context?.env)
// Get artist calendar configuration
const calendarConfig = await db
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
.bind(appointment.artistId)
.first()
if (!calendarConfig || !appointment.caldav_uid) {
return false
}
// Construct event URL
const eventUrl = `${calendarConfig.calendar_url}${appointment.caldav_uid}.ics`
// Delete from CalDAV
const success = await deleteCalendarEvent(client, eventUrl, appointment.caldav_etag || undefined)
if (success) {
// Clear CalDAV fields in database
await db
.prepare('UPDATE appointments SET caldav_uid = NULL, caldav_etag = NULL WHERE id = ?')
.bind(appointment.id)
.run()
}
return success
} catch (error) {
console.error('Error deleting appointment from calendar:', error)
return false
}
}
/**
* Pull calendar events from Nextcloud and sync to database
* This is called by background worker or manual sync
*/
export async function pullCalendarEventsToDatabase(
artistId: string,
startDate: Date,
endDate: Date,
context?: any
): Promise<SyncResult> {
const result: SyncResult = {
success: false,
eventsProcessed: 0,
eventsCreated: 0,
eventsUpdated: 0,
eventsDeleted: 0,
}
const client = createCalDAVClient()
if (!client) {
result.error = 'CalDAV not configured'
return result
}
try {
const db = getDB(context?.env)
// Get artist calendar configuration
const calendarConfig = await db
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
.bind(artistId)
.first()
if (!calendarConfig) {
result.error = `No calendar configured for artist ${artistId}`
return result
}
// Fetch events from CalDAV
const calendarEvents = await fetchCalendarEvents(
client,
calendarConfig.calendar_url,
startDate,
endDate
)
result.eventsProcessed = calendarEvents.length
// Get existing appointments for this artist in the date range
const existingAppointments = await db
.prepare(`
SELECT * FROM appointments
WHERE artist_id = ?
AND start_time >= ?
AND end_time <= ?
AND caldav_uid IS NOT NULL
`)
.bind(artistId, startDate.toISOString(), endDate.toISOString())
.all()
const existingUids = new Set(
existingAppointments.results.map((a: any) => a.caldav_uid)
)
// Process each calendar event
for (const event of calendarEvents) {
// Check if this event exists in our database
const existing = await db
.prepare('SELECT * FROM appointments WHERE caldav_uid = ? AND artist_id = ?')
.bind(event.uid, artistId)
.first()
if (existing) {
// Update existing appointment if needed
// Only update if the calendar event is different
const eventChanged =
new Date(existing.start_time).getTime() !== event.startTime.getTime() ||
new Date(existing.end_time).getTime() !== event.endTime.getTime() ||
existing.title !== event.summary
if (eventChanged) {
await db
.prepare(`
UPDATE appointments
SET title = ?, description = ?, start_time = ?, end_time = ?, caldav_etag = ?, updated_at = CURRENT_TIMESTAMP
WHERE caldav_uid = ? AND artist_id = ?
`)
.bind(
event.summary,
event.description || existing.description,
event.startTime.toISOString(),
event.endTime.toISOString(),
event.etag || null,
event.uid,
artistId
)
.run()
result.eventsUpdated++
}
existingUids.delete(event.uid)
} else {
// This is a new event from calendar - create appointment
// We'll create it as CONFIRMED since it came from the calendar
const appointmentId = crypto.randomUUID()
// Get or create a system user for calendar-sourced appointments
let systemUser = await db
.prepare('SELECT id FROM users WHERE email = ?')
.bind('calendar@system.local')
.first()
if (!systemUser) {
const userId = crypto.randomUUID()
await db
.prepare('INSERT INTO users (id, email, name, role, created_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)')
.bind(userId, 'calendar@system.local', 'Calendar System', 'CLIENT')
.run()
systemUser = { id: userId }
}
await db
.prepare(`
INSERT INTO appointments (
id, artist_id, client_id, title, description, start_time, end_time,
status, caldav_uid, caldav_etag, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'CONFIRMED', ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`)
.bind(
appointmentId,
artistId,
systemUser.id,
event.summary,
event.description || '',
event.startTime.toISOString(),
event.endTime.toISOString(),
event.uid,
event.etag || null
)
.run()
result.eventsCreated++
}
}
// Delete appointments that no longer exist in calendar
for (const uid of existingUids) {
await db
.prepare('DELETE FROM appointments WHERE caldav_uid = ? AND artist_id = ?')
.bind(uid, artistId)
.run()
result.eventsDeleted++
}
// Update sync timestamp
await db
.prepare('UPDATE artist_calendars SET last_sync_at = CURRENT_TIMESTAMP WHERE artist_id = ?')
.bind(artistId)
.run()
result.success = true
return result
} catch (error) {
console.error('Error pulling calendar events:', error)
result.error = error instanceof Error ? error.message : 'Unknown error'
return result
}
}
/**
* Check availability for a specific artist and time slot
*/
export async function checkArtistAvailability(
artistId: string,
startTime: Date,
endTime: Date,
context?: any
): Promise<{ available: boolean; reason?: string }> {
const client = createCalDAVClient()
if (!client) {
// If CalDAV is not configured, fall back to database-only check
return checkDatabaseAvailability(artistId, startTime, endTime, context)
}
try {
const db = getDB(context?.env)
// Get artist calendar configuration
const calendarConfig = await db
.prepare('SELECT * FROM artist_calendars WHERE artist_id = ?')
.bind(artistId)
.first()
if (!calendarConfig) {
// Fall back to database check
return checkDatabaseAvailability(artistId, startTime, endTime, context)
}
// Check calendar for conflicts (extend range slightly for buffer)
const bufferMinutes = 15
const checkStart = new Date(startTime.getTime() - bufferMinutes * 60 * 1000)
const checkEnd = new Date(endTime.getTime() + bufferMinutes * 60 * 1000)
const events = await fetchCalendarEvents(
client,
calendarConfig.calendar_url,
checkStart,
checkEnd
)
// Check for overlapping events
for (const event of events) {
const eventStart = new Date(event.startTime)
const eventEnd = new Date(event.endTime)
if (
(startTime >= eventStart && startTime < eventEnd) ||
(endTime > eventStart && endTime <= eventEnd) ||
(startTime <= eventStart && endTime >= eventEnd)
) {
return {
available: false,
reason: `Time slot conflicts with: ${event.summary}`,
}
}
}
return { available: true }
} catch (error) {
console.error('Error checking artist availability:', error)
// Fall back to database check on error
return checkDatabaseAvailability(artistId, startTime, endTime, context)
}
}
/**
* Database-only availability check (fallback when CalDAV unavailable)
*/
async function checkDatabaseAvailability(
artistId: string,
startTime: Date,
endTime: Date,
context?: any
): Promise<{ available: boolean; reason?: string }> {
const db = getDB(context?.env)
const conflicts = await db
.prepare(`
SELECT id, title 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 <= ?)
)
`)
.bind(
artistId,
startTime.toISOString(), startTime.toISOString(),
endTime.toISOString(), endTime.toISOString(),
startTime.toISOString(), endTime.toISOString()
)
.all()
if (conflicts.results.length > 0) {
const conflict = conflicts.results[0] as any
return {
available: false,
reason: `Time slot conflicts with existing appointment: ${conflict.title}`,
}
}
return { available: true }
}
/**
* Log sync operation to database
*/
export async function logSync(
log: Omit<CalendarSyncLog, 'id' | 'createdAt'>,
context?: any
): Promise<void> {
try {
const db = getDB(context?.env)
const logId = crypto.randomUUID()
await db
.prepare(`
INSERT INTO calendar_sync_logs (
id, artist_id, sync_type, status, error_message,
events_processed, events_created, events_updated, events_deleted,
duration_ms, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`)
.bind(
logId,
log.artistId || null,
log.syncType,
log.status,
log.errorMessage || null,
log.eventsProcessed,
log.eventsCreated,
log.eventsUpdated,
log.eventsDeleted,
log.durationMs || null
)
.run()
} catch (error) {
console.error('Error logging sync:', error)
}
}