Fastify/Postgres backend + React frontend matching the stack's app conventions, plus a hand-rolled RFC 4791 CalDAV server (caldav-adapter turned out Koa-only in practice) so calendars subscribe as genuine two-way sync in Apple/Google/Outlook. v1 scope: multiple colour-coded calendars, department/staff event tagging via live Workforce lookups, month/week/day/list views, dashboard, file attachments, activity log, and an auto-synced UK bank holidays calendar. Verified end-to-end locally against real Postgres: REST CRUD, CalDAV discovery/PROPFIND/REPORT/PUT/sync-collection, all-day date handling, system-calendar write protection, and activity logging across both the web and CalDAV write paths. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
200 lines
7.1 KiB
JavaScript
200 lines
7.1 KiB
JavaScript
// Data-access + iCalendar translation layer for the hand-rolled CalDAV
|
|
// server in routes/caldav.js. All event mutations funnel through
|
|
// routes/events.js's createEvent/updateEvent/deleteEvent/getEventDetail so
|
|
// web and CalDAV writes share one source of truth (source: 'caldav' here).
|
|
import bcrypt from 'bcryptjs'
|
|
import ICAL from 'ical.js'
|
|
import { createEvent as buildIcsEvent } from 'ics'
|
|
import { createEvent, updateEvent, deleteEvent } from '../routes/events.js'
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
|
|
// ---- auth -------------------------------------------------------------
|
|
|
|
export async function authenticateBasic(pool, username, password) {
|
|
if (!username || !password) return null
|
|
const { rows } = await pool.query('SELECT * FROM caldav_credentials WHERE username = $1', [username])
|
|
if (!rows.length) return null
|
|
const cred = rows[0]
|
|
const ok = await bcrypt.compare(password, cred.password_hash)
|
|
if (!ok) return null
|
|
pool.query('UPDATE caldav_credentials SET last_used_at = NOW() WHERE id = $1', [cred.id]).catch(() => {})
|
|
return { email: cred.user_email }
|
|
}
|
|
|
|
// ---- calendar collections ----------------------------------------------
|
|
|
|
export async function listCalendars(pool) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM calendars WHERE deleted_at IS NULL ORDER BY is_system ASC, name ASC`
|
|
)
|
|
return rows
|
|
}
|
|
|
|
export async function getCalendarBySlug(pool, slug) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM calendars WHERE slug = $1 AND deleted_at IS NULL`, [slug]
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
// Highest-water-mark timestamp for a calendar's contents — used as the
|
|
// CalendarServer getctag so clients know when to re-list without diffing
|
|
// every resource's etag.
|
|
export async function getCalendarCtag(pool, calendarId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT GREATEST(
|
|
COALESCE((SELECT MAX(updated_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NULL), 'epoch'),
|
|
COALESCE((SELECT MAX(deleted_at) FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL), 'epoch')
|
|
) AS ts`,
|
|
[calendarId]
|
|
)
|
|
const ts = rows[0]?.ts
|
|
return ts ? new Date(ts).getTime().toString() : '0'
|
|
}
|
|
|
|
export async function listEventsInCalendar(pool, calendarId) {
|
|
const { rows } = await pool.query(
|
|
`SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL ORDER BY uid`,
|
|
[calendarId]
|
|
)
|
|
return rows
|
|
}
|
|
|
|
export async function getFullEvent(pool, calendarId, uid) {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM events WHERE calendar_id = $1 AND uid = $2 AND deleted_at IS NULL`,
|
|
[calendarId, uid]
|
|
)
|
|
return rows[0] || null
|
|
}
|
|
|
|
// Events changed (created/updated) or removed (soft-deleted) since `since`
|
|
// (a Date, or null for a full initial sync). Minimal RFC 6578 support.
|
|
export async function getChangesSince(pool, calendarId, since) {
|
|
const params = [calendarId]
|
|
let sinceClause = 'TRUE'
|
|
if (since) {
|
|
params.push(since)
|
|
sinceClause = `updated_at > $2`
|
|
}
|
|
const { rows: changed } = await pool.query(
|
|
`SELECT id, uid, sequence, updated_at FROM events WHERE calendar_id = $1 AND deleted_at IS NULL AND ${sinceClause} ORDER BY updated_at`,
|
|
params
|
|
)
|
|
let removed = []
|
|
if (since) {
|
|
const { rows } = await pool.query(
|
|
`SELECT uid FROM events WHERE calendar_id = $1 AND deleted_at IS NOT NULL AND deleted_at > $2`,
|
|
[calendarId, since]
|
|
)
|
|
removed = rows
|
|
}
|
|
return { changed, removed }
|
|
}
|
|
|
|
// ---- etags --------------------------------------------------------------
|
|
|
|
export function etagFor(event) {
|
|
return `"${event.sequence}-${new Date(event.updated_at).getTime()}"`
|
|
}
|
|
|
|
// ---- iCalendar <-> our event shape ---------------------------------------
|
|
|
|
function toDateArray(d) {
|
|
const dt = new Date(d)
|
|
return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate()]
|
|
}
|
|
|
|
function toDateTimeArray(d) {
|
|
const dt = new Date(d)
|
|
return [dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate(), dt.getUTCHours(), dt.getUTCMinutes()]
|
|
}
|
|
|
|
// Generates a full VCALENDAR document containing one VEVENT. Calendar name
|
|
// is mapped one-way into CATEGORIES for context; it does not round-trip.
|
|
export function eventToIcsString(event, calendarName) {
|
|
const attrs = {
|
|
uid: event.uid,
|
|
title: event.title,
|
|
description: event.description || undefined,
|
|
location: event.location || undefined,
|
|
sequence: event.sequence,
|
|
categories: calendarName ? [calendarName] : undefined,
|
|
calName: calendarName || undefined,
|
|
startInputType: 'utc',
|
|
startOutputType: 'utc',
|
|
endInputType: 'utc',
|
|
endOutputType: 'utc',
|
|
}
|
|
|
|
if (event.all_day) {
|
|
// Our end_at is the inclusive last day; RFC 5545 requires DTEND for a
|
|
// DATE value to be the day *after* the last day (exclusive).
|
|
attrs.start = toDateArray(event.start_at)
|
|
attrs.end = toDateArray(new Date(new Date(event.end_at).getTime() + DAY_MS))
|
|
} else {
|
|
attrs.start = toDateTimeArray(event.start_at)
|
|
attrs.end = toDateTimeArray(event.end_at)
|
|
}
|
|
|
|
const { error, value } = buildIcsEvent(attrs)
|
|
if (error) throw error
|
|
return value
|
|
}
|
|
|
|
// Parses an incoming PUT body (a VCALENDAR with one VEVENT) into our event
|
|
// field shape. RRULE, if present, is ignored — v1 has no recurring events;
|
|
// every VEVENT is stored as a single one-off occurrence.
|
|
export function parseIcsEvent(icsBody) {
|
|
const jcal = ICAL.parse(icsBody)
|
|
const comp = new ICAL.Component(jcal)
|
|
const vevent = comp.getFirstSubcomponent('vevent')
|
|
if (!vevent) throw new Error('No VEVENT found in submitted iCalendar data')
|
|
const event = new ICAL.Event(vevent)
|
|
|
|
if (!event.uid) throw new Error('VEVENT missing UID')
|
|
if (!event.startDate) throw new Error('VEVENT missing DTSTART')
|
|
|
|
const allDay = event.startDate.isDate === true
|
|
const start = event.startDate.toJSDate()
|
|
let end
|
|
if (event.endDate) {
|
|
end = event.endDate.toJSDate()
|
|
} else if (event.duration) {
|
|
end = new Date(start.getTime() + event.duration.toSeconds() * 1000)
|
|
} else {
|
|
end = start
|
|
}
|
|
if (allDay) {
|
|
// Convert the RFC-mandated exclusive DTEND back to our inclusive
|
|
// last-day storage convention.
|
|
end = new Date(end.getTime() - DAY_MS)
|
|
if (end.getTime() < start.getTime()) end = start
|
|
}
|
|
|
|
return {
|
|
uid: event.uid,
|
|
title: event.summary || '(untitled)',
|
|
description: event.description || null,
|
|
location: event.location || null,
|
|
all_day: allDay,
|
|
start_at: start.toISOString(),
|
|
end_at: end.toISOString(),
|
|
sequence: Number.isFinite(event.sequence) ? event.sequence : 0,
|
|
}
|
|
}
|
|
|
|
// ---- mutation pass-through (source: 'caldav') ----------------------------
|
|
|
|
export async function createEventFromCaldav(pool, calendarId, parsed, actorEmail) {
|
|
return createEvent(pool, { calendar_id: calendarId, ...parsed }, { email: actorEmail, name: actorEmail }, 'caldav')
|
|
}
|
|
|
|
export async function updateEventFromCaldav(pool, eventId, parsed, actorEmail) {
|
|
return updateEvent(pool, eventId, parsed, { email: actorEmail, name: actorEmail }, 'caldav')
|
|
}
|
|
|
|
export async function deleteEventFromCaldav(pool, eventId, actorEmail) {
|
|
return deleteEvent(pool, eventId, { email: actorEmail, name: actorEmail }, 'caldav')
|
|
}
|