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
31
backend/src/lib/activity.js
Normal file
31
backend/src/lib/activity.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Single write path for the activity_log audit trail. Called from every
|
||||
// mutation in routes/calendars.js, routes/events.js, routes/attachments.js,
|
||||
// routes/caldav-credentials.js, and from lib/caldav-store.js (source: 'caldav').
|
||||
export async function logActivity(pool, {
|
||||
actorEmail = null,
|
||||
actorName = null,
|
||||
action,
|
||||
entityType,
|
||||
entityId = null,
|
||||
calendarId = null,
|
||||
summary,
|
||||
details = null,
|
||||
source = 'web',
|
||||
}) {
|
||||
await pool.query(
|
||||
`INSERT INTO activity_log
|
||||
(actor_email, actor_name, action, entity_type, entity_id, calendar_id, summary, details, source)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[
|
||||
actorEmail,
|
||||
actorName,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
calendarId,
|
||||
summary,
|
||||
details != null ? JSON.stringify(details) : null,
|
||||
source,
|
||||
]
|
||||
)
|
||||
}
|
||||
57
backend/src/lib/bank-holidays.js
Normal file
57
backend/src/lib/bank-holidays.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Syncs England & Wales bank holidays from gov.uk into the system
|
||||
// "Bank Holidays" calendar. Idempotent (uid is stable per date) and
|
||||
// tolerant of the upstream API being unreachable — never crashes the app.
|
||||
import { pool } from '../db.js'
|
||||
|
||||
const SOURCE_URL = 'https://www.gov.uk/bank-holidays.json'
|
||||
const DIVISION = 'england-and-wales'
|
||||
|
||||
export async function syncBankHolidays(pool) {
|
||||
let data
|
||||
try {
|
||||
const res = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(10000) })
|
||||
if (!res.ok) throw new Error(`gov.uk bank holidays fetch failed: ${res.status}`)
|
||||
data = await res.json()
|
||||
} catch (err) {
|
||||
console.error('[bank-holidays] sync failed, skipping:', err.message)
|
||||
return
|
||||
}
|
||||
|
||||
const events = data?.[DIVISION]?.events
|
||||
if (!Array.isArray(events)) {
|
||||
console.error('[bank-holidays] unexpected response shape, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
const { rows: cal } = await pool.query(`SELECT id FROM calendars WHERE slug = 'bank-holidays'`)
|
||||
if (!cal.length) {
|
||||
console.error('[bank-holidays] system calendar not found (seedDefaults should have created it), skipping')
|
||||
return
|
||||
}
|
||||
const calendarId = cal[0].id
|
||||
|
||||
for (const holiday of events) {
|
||||
const uid = `bank-holiday-${holiday.date}`
|
||||
const startAt = `${holiday.date}T00:00:00Z`
|
||||
const endAt = `${holiday.date}T00:00:00Z`
|
||||
await pool.query(
|
||||
`INSERT INTO events
|
||||
(calendar_id, uid, title, description, all_day, start_at, end_at, created_by_name, updated_at)
|
||||
VALUES ($1, $2, $3, $4, TRUE, $5, $6, 'GOV.UK Bank Holidays', NOW())
|
||||
ON CONFLICT (uid) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
start_at = EXCLUDED.start_at,
|
||||
end_at = EXCLUDED.end_at,
|
||||
updated_at = NOW()`,
|
||||
[calendarId, uid, holiday.title, holiday.notes || null, startAt, endAt]
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[bank-holidays] synced ${events.length} events`)
|
||||
}
|
||||
|
||||
export function startBankHolidaySync(app) {
|
||||
syncBankHolidays(pool).catch(err => app.log.error(err))
|
||||
setInterval(() => syncBankHolidays(pool).catch(err => app.log.error(err)), 24 * 60 * 60 * 1000)
|
||||
}
|
||||
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')
|
||||
}
|
||||
92
backend/src/lib/workforce.js
Normal file
92
backend/src/lib/workforce.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Workforce integration — department lookups for event tagging and the
|
||||
// "mine" event filter. Ported from auth/src/workforce.js (not wages' version,
|
||||
// which is wage-sync-specific). Never let a Workforce API hiccup break the
|
||||
// calendar app: callers that can degrade gracefully should catch and fall
|
||||
// back to an empty list.
|
||||
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
|
||||
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||
|
||||
let _credsCache = null // { creds, expires_at }
|
||||
let _deptsCache = null // { depts, expires_at } — separate 5-min cache, hit on every tagging picker load
|
||||
|
||||
export async function getWorkforceCreds() {
|
||||
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
|
||||
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
|
||||
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch Workforce credentials from settings: ${res.status}`)
|
||||
const creds = await res.json()
|
||||
if (!creds.bearer_token) throw new Error('Workforce bearer token not configured in settings')
|
||||
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
|
||||
return creds
|
||||
}
|
||||
|
||||
async function wfFetch(path) {
|
||||
const creds = await getWorkforceCreds()
|
||||
const baseUrl = creds.base_url || 'https://my.workforce.com'
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
headers: { Authorization: `Bearer ${creds.bearer_token}` },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Workforce API error ${res.status} at ${path}${body ? ': ' + body.slice(0, 200) : ''}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function wfFetchPaged(path) {
|
||||
const results = []
|
||||
let page = 1
|
||||
while (true) {
|
||||
const sep = path.includes('?') ? '&' : '?'
|
||||
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
|
||||
const items = Array.isArray(data) ? data : (data.users ?? data.departments ?? data.teams ?? data.locations ?? [])
|
||||
results.push(...items)
|
||||
if (items.length < 100) break
|
||||
page++
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function findEmployeeByEmail(email) {
|
||||
const data = await wfFetch(`/api/v2/users?email=${encodeURIComponent(email)}`)
|
||||
const users = Array.isArray(data) ? data : (data.users ?? [])
|
||||
return users.length > 0 ? users[0] : null
|
||||
}
|
||||
|
||||
export async function getAllDepartments() {
|
||||
if (_deptsCache && Date.now() < _deptsCache.expires_at) return _deptsCache.depts
|
||||
const depts = await wfFetchPaged('/api/v2/departments')
|
||||
_deptsCache = { depts, expires_at: Date.now() + 5 * 60_000 }
|
||||
return depts
|
||||
}
|
||||
|
||||
export async function getEmployeeDepartments(wfUserId) {
|
||||
const depts = await getAllDepartments()
|
||||
const id = String(wfUserId)
|
||||
return depts.filter(d => {
|
||||
const staff = (d.staff ?? []).map(String)
|
||||
const managers = (d.managers ?? []).map(String)
|
||||
return staff.includes(id) || managers.includes(id)
|
||||
})
|
||||
}
|
||||
|
||||
// Convenience: email -> Workforce departments, [] on any failure (unknown
|
||||
// employee, Workforce not configured, network error) so callers never need
|
||||
// their own try/catch.
|
||||
export async function getDepartmentsForEmail(email) {
|
||||
try {
|
||||
const employee = await findEmployeeByEmail(email)
|
||||
if (!employee?.id) return []
|
||||
return await getEmployeeDepartments(employee.id)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateCache() {
|
||||
_credsCache = null
|
||||
_deptsCache = null
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue