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.
304 lines
8.0 KiB
TypeScript
304 lines
8.0 KiB
TypeScript
/**
|
|
* CalDAV Client for Nextcloud Integration
|
|
*
|
|
* This module provides functions to interact with Nextcloud CalDAV server,
|
|
* handling event creation, updates, deletions, and availability checks.
|
|
*/
|
|
|
|
import { DAVClient } from 'tsdav'
|
|
import ICAL from 'ical.js'
|
|
import type { Appointment, AppointmentStatus, CalendarEvent } from '@/types/database'
|
|
|
|
// Initialize CalDAV client with Nextcloud credentials
|
|
export function createCalDAVClient(): DAVClient | null {
|
|
const baseUrl = process.env.NEXTCLOUD_BASE_URL
|
|
const username = process.env.NEXTCLOUD_USERNAME
|
|
const password = process.env.NEXTCLOUD_PASSWORD
|
|
|
|
if (!baseUrl || !username || !password) {
|
|
console.warn('CalDAV credentials not configured. Calendar sync will be disabled.')
|
|
return null
|
|
}
|
|
|
|
return new DAVClient({
|
|
serverUrl: baseUrl,
|
|
credentials: {
|
|
username,
|
|
password,
|
|
},
|
|
authMethod: 'Basic',
|
|
defaultAccountType: 'caldav',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Convert appointment to iCalendar format
|
|
*/
|
|
export function appointmentToICalendar(appointment: Appointment, artistName: string, clientName: string): string {
|
|
const comp = new ICAL.Component(['vcalendar', [], []])
|
|
comp.updatePropertyWithValue('prodid', '-//United Tattoo//Booking System//EN')
|
|
comp.updatePropertyWithValue('version', '2.0')
|
|
|
|
const vevent = new ICAL.Component('vevent')
|
|
const event = new ICAL.Event(vevent)
|
|
|
|
// Set UID - use existing caldav_uid if available
|
|
event.uid = appointment.caldav_uid || `united-tattoo-${appointment.id}`
|
|
|
|
// Set summary based on appointment status
|
|
const summaryPrefix = appointment.status === 'PENDING' ? 'REQUEST: ' : ''
|
|
event.summary = `${summaryPrefix}${clientName} - ${appointment.title || 'Tattoo Session'}`
|
|
|
|
// Set description
|
|
const description = [
|
|
`Client: ${clientName}`,
|
|
`Artist: ${artistName}`,
|
|
appointment.description ? `Description: ${appointment.description}` : '',
|
|
appointment.placement ? `Placement: ${appointment.placement}` : '',
|
|
appointment.notes ? `Notes: ${appointment.notes}` : '',
|
|
`Status: ${appointment.status}`,
|
|
appointment.depositAmount ? `Deposit: $${appointment.depositAmount}` : '',
|
|
].filter(Boolean).join('\n')
|
|
|
|
event.description = description
|
|
|
|
// Set start and end times
|
|
const startTime = ICAL.Time.fromJSDate(new Date(appointment.startTime), true)
|
|
const endTime = ICAL.Time.fromJSDate(new Date(appointment.endTime), true)
|
|
|
|
event.startDate = startTime
|
|
event.endDate = endTime
|
|
|
|
// Add custom properties
|
|
vevent.addPropertyWithValue('x-appointment-id', appointment.id)
|
|
vevent.addPropertyWithValue('x-artist-id', appointment.artistId)
|
|
vevent.addPropertyWithValue('x-client-id', appointment.clientId)
|
|
vevent.addPropertyWithValue('x-appointment-status', appointment.status)
|
|
|
|
// Set status based on appointment status
|
|
if (appointment.status === 'CONFIRMED') {
|
|
vevent.addPropertyWithValue('status', 'CONFIRMED')
|
|
} else if (appointment.status === 'CANCELLED') {
|
|
vevent.addPropertyWithValue('status', 'CANCELLED')
|
|
} else {
|
|
vevent.addPropertyWithValue('status', 'TENTATIVE')
|
|
}
|
|
|
|
comp.addSubcomponent(vevent)
|
|
|
|
return comp.toString()
|
|
}
|
|
|
|
/**
|
|
* Parse iCalendar event to CalendarEvent
|
|
*/
|
|
export function parseICalendarEvent(icsData: string): CalendarEvent | null {
|
|
try {
|
|
const jCalData = ICAL.parse(icsData)
|
|
const comp = new ICAL.Component(jCalData)
|
|
const vevent = comp.getFirstSubcomponent('vevent')
|
|
|
|
if (!vevent) {
|
|
return null
|
|
}
|
|
|
|
const event = new ICAL.Event(vevent)
|
|
|
|
return {
|
|
uid: event.uid,
|
|
summary: event.summary || '',
|
|
description: event.description || '',
|
|
startTime: event.startDate.toJSDate(),
|
|
endTime: event.endDate.toJSDate(),
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing iCalendar event:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create or update an event in CalDAV server
|
|
*/
|
|
export async function createOrUpdateCalendarEvent(
|
|
client: DAVClient,
|
|
calendarUrl: string,
|
|
appointment: Appointment,
|
|
artistName: string,
|
|
clientName: string,
|
|
existingEtag?: string
|
|
): Promise<{ uid: string; etag?: string; url: string } | null> {
|
|
try {
|
|
await client.login()
|
|
|
|
const icsData = appointmentToICalendar(appointment, artistName, clientName)
|
|
const uid = appointment.caldav_uid || `united-tattoo-${appointment.id}`
|
|
|
|
// Construct the event URL
|
|
const eventUrl = `${calendarUrl}${uid}.ics`
|
|
|
|
// If we have an etag, this is an update
|
|
if (existingEtag) {
|
|
const response = await client.updateCalendarObject({
|
|
calendarObject: {
|
|
url: eventUrl,
|
|
data: icsData,
|
|
etag: existingEtag,
|
|
},
|
|
})
|
|
|
|
return {
|
|
uid,
|
|
etag: response.headers?.get('etag') || undefined,
|
|
url: eventUrl,
|
|
}
|
|
} else {
|
|
// This is a new event
|
|
const response = await client.createCalendarObject({
|
|
calendar: {
|
|
url: calendarUrl,
|
|
},
|
|
filename: `${uid}.ics`,
|
|
iCalString: icsData,
|
|
})
|
|
|
|
return {
|
|
uid,
|
|
etag: response.headers?.get('etag') || undefined,
|
|
url: eventUrl,
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error creating/updating calendar event:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete an event from CalDAV server
|
|
*/
|
|
export async function deleteCalendarEvent(
|
|
client: DAVClient,
|
|
eventUrl: string,
|
|
etag?: string
|
|
): Promise<boolean> {
|
|
try {
|
|
await client.login()
|
|
|
|
await client.deleteCalendarObject({
|
|
calendarObject: {
|
|
url: eventUrl,
|
|
etag: etag || '',
|
|
},
|
|
})
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error('Error deleting calendar event:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch all events from a calendar within a date range
|
|
*/
|
|
export async function fetchCalendarEvents(
|
|
client: DAVClient,
|
|
calendarUrl: string,
|
|
startDate: Date,
|
|
endDate: Date
|
|
): Promise<CalendarEvent[]> {
|
|
try {
|
|
await client.login()
|
|
|
|
const objects = await client.fetchCalendarObjects({
|
|
calendar: {
|
|
url: calendarUrl,
|
|
},
|
|
timeRange: {
|
|
start: startDate.toISOString(),
|
|
end: endDate.toISOString(),
|
|
},
|
|
})
|
|
|
|
const events: CalendarEvent[] = []
|
|
|
|
for (const obj of objects) {
|
|
if (obj.data) {
|
|
const event = parseICalendarEvent(obj.data)
|
|
if (event) {
|
|
events.push({
|
|
...event,
|
|
etag: obj.etag,
|
|
url: obj.url,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return events
|
|
} catch (error) {
|
|
console.error('Error fetching calendar events:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a time slot is available (no conflicts)
|
|
*/
|
|
export async function checkTimeSlotAvailability(
|
|
client: DAVClient,
|
|
calendarUrl: string,
|
|
startTime: Date,
|
|
endTime: Date
|
|
): Promise<boolean> {
|
|
try {
|
|
const events = await fetchCalendarEvents(client, calendarUrl, startTime, endTime)
|
|
|
|
// Check for any overlapping events
|
|
for (const event of events) {
|
|
const eventStart = new Date(event.startTime)
|
|
const eventEnd = new Date(event.endTime)
|
|
|
|
// Check for overlap
|
|
if (
|
|
(startTime >= eventStart && startTime < eventEnd) ||
|
|
(endTime > eventStart && endTime <= eventEnd) ||
|
|
(startTime <= eventStart && endTime >= eventEnd)
|
|
) {
|
|
return false // Slot is not available
|
|
}
|
|
}
|
|
|
|
return true // Slot is available
|
|
} catch (error) {
|
|
console.error('Error checking time slot availability:', error)
|
|
// In case of error, assume slot is unavailable for safety
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all blocked time slots for a calendar within a date range
|
|
*/
|
|
export async function getBlockedTimeSlots(
|
|
client: DAVClient,
|
|
calendarUrl: string,
|
|
startDate: Date,
|
|
endDate: Date
|
|
): Promise<Array<{ start: Date; end: Date; summary: string }>> {
|
|
try {
|
|
const events = await fetchCalendarEvents(client, calendarUrl, startDate, endDate)
|
|
|
|
return events.map(event => ({
|
|
start: new Date(event.startTime),
|
|
end: new Date(event.endTime),
|
|
summary: event.summary,
|
|
}))
|
|
} catch (error) {
|
|
console.error('Error getting blocked time slots:', error)
|
|
return []
|
|
}
|
|
}
|
|
|