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:
commit
bf5557d277
53 changed files with 12529 additions and 0 deletions
200
backend/src/lib/caldav-store.js
Normal file
200
backend/src/lib/caldav-store.js
Normal 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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue