Add calendar app — shared events calendar with departments, staff tagging, bank holidays and two-way phone sync

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>
This commit is contained in:
jtricerolph 2026-07-24 16:36:37 +00:00
commit bf5557d277
53 changed files with 12529 additions and 0 deletions

View file

@ -0,0 +1,200 @@
// 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')
}