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
8
backend/Dockerfile
Normal file
8
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
RUN npm install --omit=dev
|
||||
COPY src ./src
|
||||
RUN mkdir -p /app/uploads
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/index.js"]
|
||||
1507
backend/package-lock.json
generated
Normal file
1507
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
backend/package.json
Normal file
22
backend/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "hnf-calendar-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.4.0",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@xmldom/xmldom": "^0.8.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^4.28.1",
|
||||
"ical.js": "^2.1.0",
|
||||
"ics": "^3.8.1",
|
||||
"jose": "^5.9.6",
|
||||
"pg": "^8.13.1"
|
||||
}
|
||||
}
|
||||
57
backend/src/auth.js
Normal file
57
backend/src/auth.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { jwtVerify } from 'jose'
|
||||
import { isOnsite } from './ip-check.js'
|
||||
|
||||
const APP_SLUG = process.env.APP_SLUG || 'calendar'
|
||||
const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '')
|
||||
|
||||
export async function requireAuth(request, reply) {
|
||||
const token = request.cookies?.hnf_session
|
||||
if (!token) return reply.status(401).send({ error: 'Not authenticated' })
|
||||
|
||||
let payload
|
||||
try {
|
||||
const { payload: p } = await jwtVerify(token, secret)
|
||||
payload = p
|
||||
} catch {
|
||||
return reply.status(401).send({ error: 'Invalid session' })
|
||||
}
|
||||
|
||||
if (!payload.apps?.includes(APP_SLUG)) {
|
||||
return reply.status(403).send({ error: 'No permission for this app' })
|
||||
}
|
||||
|
||||
if (!payload.offsite_allowed) {
|
||||
const clientIP = request.headers['x-real-ip'] || request.ip
|
||||
if (!(await isOnsite(clientIP))) {
|
||||
return reply.status(403).send({ error: 'Access restricted to site network' })
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = `${APP_SLUG}:`
|
||||
let caps
|
||||
if (Array.isArray(payload.caps)) {
|
||||
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
|
||||
} else {
|
||||
// Legacy token — grant all non-settings caps until re-login
|
||||
caps = ['view', 'guest_details', 'rate_details', 'view_all_notes', 'complete_tasks', 'update_status']
|
||||
}
|
||||
|
||||
request.user = {
|
||||
email: payload.sub,
|
||||
name: payload.name,
|
||||
is_admin: payload.is_admin ?? false,
|
||||
caps,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasCap(request, cap) {
|
||||
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
|
||||
}
|
||||
|
||||
export function requireCap(cap) {
|
||||
return async (request, reply) => {
|
||||
if (!hasCap(request, cap)) {
|
||||
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
106
backend/src/db.js
Normal file
106
backend/src/db.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import pg from 'pg'
|
||||
|
||||
const { Pool } = pg
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
export async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS calendars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
calendar_id INT NOT NULL REFERENCES calendars(id),
|
||||
uid TEXT NOT NULL UNIQUE, -- iCalendar UID, e.g. '<uuid>@calendar.hotelnumberfour.com'
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sequence INT NOT NULL DEFAULT 0,
|
||||
created_by TEXT, -- email
|
||||
created_by_name TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS events_calendar_idx ON events (calendar_id);
|
||||
CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at);
|
||||
CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_departments (
|
||||
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
department_id TEXT NOT NULL,
|
||||
department_name TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, department_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_assignees (
|
||||
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_email TEXT NOT NULL,
|
||||
user_name TEXT,
|
||||
PRIMARY KEY (event_id, user_email)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_attachments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL, -- relative path under uploads dir
|
||||
mime_type TEXT,
|
||||
size_bytes INT,
|
||||
uploaded_by TEXT, -- email
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS caldav_credentials (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_email TEXT NOT NULL,
|
||||
username TEXT NOT NULL UNIQUE, -- generated, e.g. '<localpart>-<random6>'
|
||||
password_hash TEXT NOT NULL, -- bcrypt
|
||||
label TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
actor_email TEXT,
|
||||
actor_name TEXT,
|
||||
action TEXT NOT NULL, -- created | updated | deleted
|
||||
entity_type TEXT NOT NULL, -- event | calendar | attachment | caldav_credential
|
||||
entity_id INT,
|
||||
calendar_id INT,
|
||||
summary TEXT NOT NULL,
|
||||
details JSONB,
|
||||
source TEXT NOT NULL DEFAULT 'web', -- web | caldav
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS activity_log_calendar_idx ON activity_log (calendar_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id);
|
||||
`)
|
||||
|
||||
await seedDefaults()
|
||||
}
|
||||
|
||||
async function seedDefaults() {
|
||||
await pool.query(
|
||||
`INSERT INTO calendars (slug, name, color, is_system)
|
||||
VALUES ('bank-holidays', 'Bank Holidays', '#64748b', TRUE)
|
||||
ON CONFLICT (slug) DO NOTHING`
|
||||
)
|
||||
await pool.query(
|
||||
`INSERT INTO calendars (slug, name, color, is_system)
|
||||
VALUES ('general', 'All Staff', '#c9a84c', FALSE)
|
||||
ON CONFLICT (slug) DO NOTHING`
|
||||
)
|
||||
}
|
||||
56
backend/src/index.js
Normal file
56
backend/src/index.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Fastify from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import multipart from '@fastify/multipart'
|
||||
import staticFiles from '@fastify/static'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
import { initDb } from './db.js'
|
||||
import { startBankHolidaySync } from './lib/bank-holidays.js'
|
||||
import { calendarRoutes } from './routes/calendars.js'
|
||||
import { eventRoutes } from './routes/events.js'
|
||||
import { attachmentRoutes } from './routes/attachments.js'
|
||||
import { activityRoutes } from './routes/activity.js'
|
||||
import { departmentRoutes } from './routes/departments.js'
|
||||
import { caldavCredentialRoutes } from './routes/caldav-credentials.js'
|
||||
import { caldavRoutes } from './routes/caldav.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const UPLOADS_DIR = join(__dirname, '..', 'uploads')
|
||||
|
||||
// ignoreTrailingSlash matters for the hand-rolled CalDAV routes — clients
|
||||
// vary on whether they request collection URLs with or without a trailing
|
||||
// slash, and RFC 4918 treats them as the same resource.
|
||||
const app = Fastify({ logger: true, trustProxy: true, ignoreTrailingSlash: true })
|
||||
const startedAt = Date.now()
|
||||
|
||||
await app.register(cookie)
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || false,
|
||||
credentials: true,
|
||||
})
|
||||
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
await app.register(staticFiles, {
|
||||
root: UPLOADS_DIR,
|
||||
prefix: '/api/uploads/',
|
||||
decorateReply: false,
|
||||
})
|
||||
|
||||
app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) }))
|
||||
|
||||
await app.register(calendarRoutes)
|
||||
await app.register(eventRoutes)
|
||||
await app.register(attachmentRoutes, { uploadsDir: UPLOADS_DIR })
|
||||
await app.register(activityRoutes)
|
||||
await app.register(departmentRoutes)
|
||||
await app.register(caldavCredentialRoutes)
|
||||
await app.register(caldavRoutes)
|
||||
|
||||
try {
|
||||
await initDb()
|
||||
startBankHolidaySync(app)
|
||||
await app.listen({ port: 3001, host: '0.0.0.0' })
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
80
backend/src/ip-check.js
Normal file
80
backend/src/ip-check.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import dns from 'dns/promises'
|
||||
|
||||
const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim()
|
||||
const matchers = raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
const TTL = 5 * 60 * 1000
|
||||
const cache = new Map()
|
||||
|
||||
const PUBLIC_IP_URLS = [
|
||||
'https://api.ipify.org',
|
||||
'https://ifconfig.co/ip',
|
||||
'https://icanhazip.com',
|
||||
]
|
||||
|
||||
function normalizeIP(ip) {
|
||||
return ip?.startsWith('::ffff:') ? ip.slice(7) : ip
|
||||
}
|
||||
|
||||
function isIPv4(s) {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(s)
|
||||
}
|
||||
|
||||
function ipInCidr(ip, cidr) {
|
||||
const [range, bits] = cidr.split('/')
|
||||
if (!isIPv4(ip) || !isIPv4(range)) return false
|
||||
const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0
|
||||
const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0
|
||||
return (toInt(ip) & mask) === (toInt(range) & mask)
|
||||
}
|
||||
|
||||
async function fetchPublicIP() {
|
||||
for (const url of PUBLIC_IP_URLS) {
|
||||
try {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), 4000)
|
||||
const res = await fetch(url, { signal: ctrl.signal })
|
||||
clearTimeout(timer)
|
||||
if (!res.ok) continue
|
||||
const ip = (await res.text()).trim()
|
||||
if (isIPv4(ip)) return ip
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveDynamic(key, resolver) {
|
||||
const hit = cache.get(key)
|
||||
if (hit && Date.now() < hit.expiry) return hit.ip
|
||||
const ip = await resolver()
|
||||
if (ip) {
|
||||
cache.set(key, { ip, expiry: Date.now() + TTL })
|
||||
return ip
|
||||
}
|
||||
return hit ? hit.ip : null
|
||||
}
|
||||
|
||||
export async function isOnsite(requestIP) {
|
||||
if (matchers.length === 0 || matchers.includes('disabled')) return true
|
||||
const ip = normalizeIP(requestIP)
|
||||
if (!ip) return false
|
||||
|
||||
for (const m of matchers) {
|
||||
if (m === 'auto') {
|
||||
const pub = await resolveDynamic('auto', fetchPublicIP)
|
||||
if (pub && ip === pub) return true
|
||||
} else if (m.includes('/')) {
|
||||
if (ipInCidr(ip, m)) return true
|
||||
} else if (/[a-zA-Z]/.test(m)) {
|
||||
const resolved = await resolveDynamic(m, async () => {
|
||||
try { return (await dns.resolve4(m))[0] } catch { return null }
|
||||
})
|
||||
if (resolved && ip === resolved) return true
|
||||
} else {
|
||||
if (ip === m) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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
|
||||
}
|
||||
38
backend/src/routes/activity.js
Normal file
38
backend/src/routes/activity.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { requireAuth, hasCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
|
||||
// Viewing the history of one specific event (the EventForm's inline "History"
|
||||
// panel) only needs `view` — it's no more sensitive than the event itself.
|
||||
// The unscoped/cross-calendar activity log is the admin-only surface.
|
||||
async function requireActivityAccess(req, reply) {
|
||||
const cap = req.query?.event_id ? 'view' : 'admin'
|
||||
if (!hasCap(req, cap)) {
|
||||
return reply.status(403).send({ error: `Missing capability: ${cap}` })
|
||||
}
|
||||
}
|
||||
|
||||
export async function activityRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/activity?calendar_id=&event_id=&actor_email=&from=&to=&limit=50&offset=0
|
||||
app.get('/api/activity', { preHandler: requireActivityAccess }, async (req) => {
|
||||
const q = req.query || {}
|
||||
const clauses = []
|
||||
const vals = []
|
||||
const push = v => { vals.push(v); return `$${vals.length}` }
|
||||
|
||||
if (q.calendar_id) clauses.push(`calendar_id = ${push(parseInt(q.calendar_id))}`)
|
||||
if (q.event_id) clauses.push(`entity_type = 'event' AND entity_id = ${push(parseInt(q.event_id))}`)
|
||||
if (q.actor_email) clauses.push(`actor_email = ${push(q.actor_email)}`)
|
||||
if (q.from) clauses.push(`created_at >= ${push(`${q.from}T00:00:00Z`)}`)
|
||||
if (q.to) clauses.push(`created_at <= ${push(`${q.to}T23:59:59Z`)}`)
|
||||
|
||||
const limit = Math.min(parseInt(q.limit) || 50, 200)
|
||||
const offset = parseInt(q.offset) || 0
|
||||
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
|
||||
const sql = `SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ${push(limit)} OFFSET ${push(offset)}`
|
||||
const { rows } = await pool.query(sql, vals)
|
||||
return rows
|
||||
})
|
||||
}
|
||||
67
backend/src/routes/attachments.js
Normal file
67
backend/src/routes/attachments.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { logActivity } from '../lib/activity.js'
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { join, extname } from 'path'
|
||||
|
||||
export async function attachmentRoutes(app, opts) {
|
||||
const UPLOADS_DIR = opts.uploadsDir
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// POST /api/events/:id/attachments — multipart, field "file"
|
||||
app.post('/api/events/:id/attachments', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||
const eventId = parseInt(req.params.id)
|
||||
const { rows } = await pool.query('SELECT id, calendar_id FROM events WHERE id = $1 AND deleted_at IS NULL', [eventId])
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Event not found' })
|
||||
const event = rows[0]
|
||||
|
||||
const part = await req.file()
|
||||
if (!part) return reply.status(400).send({ error: 'No file uploaded' })
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of part.file) chunks.push(chunk)
|
||||
const buffer = Buffer.concat(chunks)
|
||||
|
||||
const ext = extname(part.filename || '') || ''
|
||||
const storedName = `${randomUUID()}${ext}`
|
||||
const storedPath = `events/${eventId}/${storedName}`
|
||||
const dir = join(UPLOADS_DIR, 'events', String(eventId))
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, storedName), buffer)
|
||||
|
||||
const { rows: ins } = await pool.query(
|
||||
`INSERT INTO event_attachments (event_id, filename, stored_path, mime_type, size_bytes, uploaded_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[eventId, part.filename || storedName, storedPath, part.mimetype || null, buffer.length, req.user.email]
|
||||
)
|
||||
const attachment = ins[0]
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'attachment',
|
||||
entityId: attachment.id, calendarId: event.calendar_id, summary: `Added attachment "${attachment.filename}"`,
|
||||
})
|
||||
|
||||
return reply.status(201).send({ ...attachment, url: `/api/uploads/${attachment.stored_path}` })
|
||||
})
|
||||
|
||||
// DELETE /api/attachments/:id
|
||||
app.delete('/api/attachments/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.*, e.calendar_id FROM event_attachments a JOIN events e ON e.id = a.event_id WHERE a.id = $1`,
|
||||
[req.params.id]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Attachment not found' })
|
||||
const attachment = rows[0]
|
||||
|
||||
await unlink(join(UPLOADS_DIR, attachment.stored_path)).catch(() => {})
|
||||
await pool.query('DELETE FROM event_attachments WHERE id = $1', [attachment.id])
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'attachment',
|
||||
entityId: attachment.id, calendarId: attachment.calendar_id, summary: `Deleted attachment "${attachment.filename}"`,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
70
backend/src/routes/caldav-credentials.js
Normal file
70
backend/src/routes/caldav-credentials.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { randomBytes } from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { logActivity } from '../lib/activity.js'
|
||||
|
||||
// CalDAV requests authenticate via HTTP Basic against caldav_credentials, NOT
|
||||
// the session cookie/JWT — there is no live capability check available to a
|
||||
// Basic-Auth CalDAV request today (no service-to-service capability endpoint
|
||||
// exists in `auth` yet), so generating a credential is gated on
|
||||
// calendar:edit at creation time (via this normal cookie-authenticated
|
||||
// route) and, once issued, a valid credential grants full CalDAV read/write
|
||||
// consistent with v1's flat, app-wide permission model. Revisit when/if a
|
||||
// cross-service capability check exists.
|
||||
export async function caldavCredentialRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
app.addHook('preHandler', requireCap('edit'))
|
||||
|
||||
// GET /api/caldav-credentials — the current user's own credentials
|
||||
app.get('/api/caldav-credentials', async (req) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, username, label, created_at, last_used_at
|
||||
FROM caldav_credentials WHERE user_email = $1 ORDER BY created_at DESC`,
|
||||
[req.user.email]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/caldav-credentials — body { label? }; password shown once
|
||||
app.post('/api/caldav-credentials', async (req, reply) => {
|
||||
const { label } = req.body || {}
|
||||
const localpart = req.user.email.split('@')[0].replace(/[^a-z0-9]/gi, '').toLowerCase() || 'user'
|
||||
const username = `${localpart}-${randomBytes(3).toString('hex')}`
|
||||
const password = randomBytes(18).toString('base64url')
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO caldav_credentials (user_email, username, password_hash, label)
|
||||
VALUES ($1,$2,$3,$4) RETURNING id, username, label, created_at`,
|
||||
[req.user.email, username, passwordHash, label || null]
|
||||
)
|
||||
const credential = rows[0]
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'caldav_credential',
|
||||
entityId: credential.id, summary: `Created CalDAV credential "${username}"`,
|
||||
details: { username, label: label || null },
|
||||
})
|
||||
|
||||
return reply.status(201).send({ id: credential.id, username: credential.username, password, label: credential.label })
|
||||
})
|
||||
|
||||
// DELETE /api/caldav-credentials/:id — only if it belongs to the requesting user
|
||||
app.delete('/api/caldav-credentials/:id', async (req, reply) => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM caldav_credentials WHERE id = $1 AND user_email = $2`,
|
||||
[req.params.id, req.user.email]
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'Credential not found' })
|
||||
|
||||
await pool.query('DELETE FROM caldav_credentials WHERE id = $1', [rows[0].id])
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'caldav_credential',
|
||||
entityId: rows[0].id, summary: `Deleted CalDAV credential "${rows[0].username}"`,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
454
backend/src/routes/caldav.js
Normal file
454
backend/src/routes/caldav.js
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
// CalDAV server — hand-rolled RFC 4791 subset, NOT the `caldav-adapter` npm
|
||||
// package. `caldav-adapter` (evaluated first, per spec) turned out to be a
|
||||
// Koa-only middleware in practice: every route handler in its source
|
||||
// (routes/calendar/*.js, routes/principal/*.js) is written directly against
|
||||
// a Koa `ctx` (ctx.state, ctx.body, ctx.status, ctx.redirect, ctx.response.set)
|
||||
// with no framework-agnostic entry point, despite "fastify" appearing in its
|
||||
// package.json keywords. Wrapping that would mean building a full Koa-context
|
||||
// shim around Fastify's request/reply for dozens of call sites — more risk
|
||||
// than value versus a minimal, purpose-built implementation. Fastify natively
|
||||
// supports the WebDAV verbs we need (PROPFIND, REPORT, MKCALENDAR are all in
|
||||
// fastify/lib/httpMethods.js and in Node's http.METHODS), so routing them as
|
||||
// plain Fastify routes is clean.
|
||||
//
|
||||
// Scope (deliberately minimal — only what Apple Calendar / Thunderbird need
|
||||
// to sync, not the full RFC 4791 surface):
|
||||
// - PROPFIND: root discovery, principal, calendar-home-set, calendar
|
||||
// collections (depth 0/1), and per-resource props during depth-1 listing.
|
||||
// - REPORT: calendar-query (with optional VEVENT time-range filter),
|
||||
// calendar-multiget, sync-collection (RFC 6578, token = an ISO timestamp
|
||||
// watermark — good enough for incremental sync, not a full change log).
|
||||
// - GET/HEAD/PUT/DELETE on individual .ics resources, with If-Match /
|
||||
// If-None-Match handled via a sequence+updated_at ETag.
|
||||
// - MKCALENDAR always rejected (405) — v1 has no CalDAV calendar creation.
|
||||
// - No RRULE support: incoming RRULEs are silently ignored (event is
|
||||
// stored as a single one-off); v1 has no recurring events at all.
|
||||
// - All-day events use DATE values only (never DATE-TIME), converting
|
||||
// between our inclusive-last-day storage and RFC 5545's exclusive DTEND.
|
||||
//
|
||||
// Auth is HTTP Basic against caldav_credentials (see lib/caldav-store.js),
|
||||
// completely separate from the cookie/JWT session used by /api/*.
|
||||
import { DOMParser } from '@xmldom/xmldom'
|
||||
import { pool } from '../db.js'
|
||||
import {
|
||||
authenticateBasic,
|
||||
listCalendars,
|
||||
getCalendarBySlug,
|
||||
getCalendarCtag,
|
||||
listEventsInCalendar,
|
||||
getFullEvent,
|
||||
getChangesSince,
|
||||
etagFor,
|
||||
eventToIcsString,
|
||||
parseIcsEvent,
|
||||
createEventFromCaldav,
|
||||
updateEventFromCaldav,
|
||||
deleteEventFromCaldav,
|
||||
} from '../lib/caldav-store.js'
|
||||
|
||||
// Must match frontend/nginx.conf's `location /calendar/caldav/` -> backend
|
||||
// `/caldav/` mapping. Hrefs in XML responses are client-facing paths, so
|
||||
// they need the external `/calendar` prefix nginx strips on the way in.
|
||||
const CALDAV_BASE = '/calendar/caldav'
|
||||
|
||||
// ---- small XML helpers (hand-written; no XML builder dependency needed for output) ----
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]))
|
||||
}
|
||||
|
||||
function propText(tag, value) {
|
||||
return ` <${tag}>${xmlEscape(value)}</${tag}>`
|
||||
}
|
||||
|
||||
function propRaw(tag, innerXml) {
|
||||
return ` <${tag}>${innerXml}</${tag}>`
|
||||
}
|
||||
|
||||
function propHref(tag, href) {
|
||||
return ` <${tag}><D:href>${xmlEscape(href)}</D:href></${tag}>`
|
||||
}
|
||||
|
||||
function davResponse(href, propsXml, status = 'HTTP/1.1 200 OK') {
|
||||
return ` <D:response>\n <D:href>${xmlEscape(href)}</D:href>\n <D:propstat>\n <D:prop>\n${propsXml}\n </D:prop>\n <D:status>${status}</D:status>\n </D:propstat>\n </D:response>`
|
||||
}
|
||||
|
||||
function davResponseNotFound(href) {
|
||||
return ` <D:response>\n <D:href>${xmlEscape(href)}</D:href>\n <D:status>HTTP/1.1 404 Not Found</D:status>\n </D:response>`
|
||||
}
|
||||
|
||||
function multistatus(responsesXml, extraTail = '') {
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">\n${responsesXml}${extraTail}\n</D:multistatus>`
|
||||
}
|
||||
|
||||
function parseXml(str) {
|
||||
if (!str) return null
|
||||
try {
|
||||
return new DOMParser({
|
||||
errorHandler: { warning: () => {}, error: () => {}, fatalError: (msg) => { throw new Error(msg) } },
|
||||
}).parseFromString(str, 'text/xml')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function icsDateTimeToDate(s) {
|
||||
const m = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/.exec(String(s || ''))
|
||||
if (!m) return null
|
||||
const [, y, mo, d, , h = '00', mi = '00', se = '00'] = m
|
||||
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +se))
|
||||
}
|
||||
|
||||
function hrefToUid(href) {
|
||||
if (!href) return null
|
||||
const clean = href.split('?')[0].replace(/\/$/, '')
|
||||
const segments = clean.split('/').filter(Boolean)
|
||||
const last = segments[segments.length - 1]
|
||||
if (!last) return null
|
||||
return decodeURIComponent(last.replace(/\.ics$/i, ''))
|
||||
}
|
||||
|
||||
function syncTokenFor(date) {
|
||||
return `sync-${date.toISOString()}`
|
||||
}
|
||||
|
||||
function tokenFromSyncToken(token) {
|
||||
const m = /^sync-(.+)$/.exec(String(token || ''))
|
||||
if (!m) return null
|
||||
const d = new Date(m[1])
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// ---- href builders (all client-facing, i.e. prefixed with CALDAV_BASE) ----
|
||||
|
||||
function encId(s) {
|
||||
return encodeURIComponent(s)
|
||||
}
|
||||
function principalHref(email) {
|
||||
return `${CALDAV_BASE}/principals/${encId(email)}/`
|
||||
}
|
||||
function calendarHomeHref(email) {
|
||||
return `${CALDAV_BASE}/calendars/${encId(email)}/`
|
||||
}
|
||||
function calendarHref(email, slug) {
|
||||
return `${CALDAV_BASE}/calendars/${encId(email)}/${encId(slug)}/`
|
||||
}
|
||||
function eventHref(email, slug, uid) {
|
||||
return `${CALDAV_BASE}/calendars/${encId(email)}/${encId(slug)}/${encId(uid)}.ics`
|
||||
}
|
||||
|
||||
function eventPropsXml(href, event, calendarName, includeData) {
|
||||
const parts = [propText('D:getetag', etagFor(event))]
|
||||
if (includeData) {
|
||||
parts.push(` <C:calendar-data>${xmlEscape(eventToIcsString(event, calendarName))}</C:calendar-data>`)
|
||||
}
|
||||
return davResponse(href, parts.join('\n'))
|
||||
}
|
||||
|
||||
// ---- auth ----------------------------------------------------------------
|
||||
|
||||
async function caldavAuth(req, reply) {
|
||||
if (req.method === 'OPTIONS') return
|
||||
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Basic ')) {
|
||||
reply.header('WWW-Authenticate', 'Basic realm="calendar"')
|
||||
return reply.code(401).send()
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8')
|
||||
const idx = decoded.indexOf(':')
|
||||
const username = idx === -1 ? decoded : decoded.slice(0, idx)
|
||||
const password = idx === -1 ? '' : decoded.slice(idx + 1)
|
||||
|
||||
const user = await authenticateBasic(pool, username, password)
|
||||
if (!user) {
|
||||
reply.header('WWW-Authenticate', 'Basic realm="calendar"')
|
||||
return reply.code(401).send()
|
||||
}
|
||||
req.caldavUser = user
|
||||
}
|
||||
|
||||
// ---- REPORT sub-handlers ---------------------------------------------------
|
||||
|
||||
async function handleCalendarQuery(reply, email, cal, doc) {
|
||||
const { rows: events } = await pool.query(
|
||||
'SELECT * FROM events WHERE calendar_id = $1 AND deleted_at IS NULL ORDER BY uid', [cal.id]
|
||||
)
|
||||
let filtered = events
|
||||
const timeRangeEl = doc?.getElementsByTagNameNS?.('urn:ietf:params:xml:ns:caldav', 'time-range')?.[0]
|
||||
if (timeRangeEl) {
|
||||
const start = icsDateTimeToDate(timeRangeEl.getAttribute('start'))
|
||||
const end = icsDateTimeToDate(timeRangeEl.getAttribute('end'))
|
||||
filtered = events.filter(e =>
|
||||
(!end || new Date(e.start_at) <= end) && (!start || new Date(e.end_at) >= start)
|
||||
)
|
||||
}
|
||||
|
||||
const responses = filtered.map(e => eventPropsXml(eventHref(email, cal.slug, e.uid), e, cal.name, true))
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||
}
|
||||
|
||||
async function handleMultiget(reply, email, cal, doc) {
|
||||
const hrefEls = doc?.getElementsByTagNameNS?.('DAV:', 'href') || []
|
||||
const responses = []
|
||||
for (let i = 0; i < hrefEls.length; i++) {
|
||||
const uid = hrefToUid(hrefEls[i].textContent)
|
||||
if (!uid) continue
|
||||
const ev = await getFullEvent(pool, cal.id, uid)
|
||||
if (!ev) continue
|
||||
responses.push(eventPropsXml(eventHref(email, cal.slug, ev.uid), ev, cal.name, true))
|
||||
}
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||
}
|
||||
|
||||
async function handleSyncCollection(reply, email, cal, doc) {
|
||||
const tokenEl = doc?.getElementsByTagNameNS?.('DAV:', 'sync-token')?.[0]
|
||||
const since = tokenEl?.textContent ? tokenFromSyncToken(tokenEl.textContent.trim()) : null
|
||||
|
||||
// Capture the new watermark BEFORE querying, not after: if a write commits
|
||||
// in the gap between the query and the token, we want the next sync to
|
||||
// re-see it (harmless — just re-delivers an already-known event) rather
|
||||
// than silently drop it (the token would then be newer than the write's
|
||||
// updated_at, so a `since`-filtered query would never surface it again).
|
||||
const newToken = syncTokenFor(new Date())
|
||||
const { changed, removed } = await getChangesSince(pool, cal.id, since)
|
||||
const responses = []
|
||||
for (const partial of changed) {
|
||||
const full = await getFullEvent(pool, cal.id, partial.uid)
|
||||
if (!full) continue
|
||||
responses.push(eventPropsXml(eventHref(email, cal.slug, full.uid), full, cal.name, true))
|
||||
}
|
||||
for (const r of removed) {
|
||||
responses.push(davResponseNotFound(eventHref(email, cal.slug, r.uid)))
|
||||
}
|
||||
|
||||
const tail = `\n <D:sync-token>${xmlEscape(newToken)}</D:sync-token>`
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n'), tail))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export async function caldavRoutes(app) {
|
||||
// Raw string body for XML (PROPFIND/REPORT) and text/calendar (PUT) —
|
||||
// none of these match Fastify's default JSON parser.
|
||||
app.addContentTypeParser('*', { parseAs: 'string' }, (req, body, done) => done(null, body))
|
||||
|
||||
app.addHook('preHandler', caldavAuth)
|
||||
|
||||
// ---- discovery ----
|
||||
|
||||
app.route({
|
||||
method: ['OPTIONS'],
|
||||
url: '/caldav',
|
||||
handler: optionsHandler,
|
||||
})
|
||||
app.route({
|
||||
method: ['OPTIONS'],
|
||||
url: '/caldav/*',
|
||||
handler: optionsHandler,
|
||||
})
|
||||
function optionsHandler(req, reply) {
|
||||
reply
|
||||
.header('Allow', 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, REPORT')
|
||||
.header('DAV', '1, 2, 3, calendar-access')
|
||||
.code(200).send()
|
||||
}
|
||||
|
||||
app.route({
|
||||
method: ['PROPFIND'],
|
||||
url: '/caldav/',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const body = multistatus(davResponse(`${CALDAV_BASE}/`, [
|
||||
propHref('D:current-user-principal', principalHref(email)),
|
||||
propRaw('D:resourcetype', '<D:collection/>'),
|
||||
].join('\n')))
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(body)
|
||||
},
|
||||
})
|
||||
|
||||
// ---- principal ----
|
||||
|
||||
app.route({
|
||||
method: ['PROPFIND'],
|
||||
url: '/caldav/principals/:principal/',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const body = multistatus(davResponse(principalHref(email), [
|
||||
propRaw('D:resourcetype', '<D:collection/><D:principal/>'),
|
||||
propText('D:displayname', email),
|
||||
propHref('C:calendar-home-set', calendarHomeHref(email)),
|
||||
propHref('D:current-user-principal', principalHref(email)),
|
||||
propHref('D:principal-URL', principalHref(email)),
|
||||
].join('\n')))
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(body)
|
||||
},
|
||||
})
|
||||
|
||||
// ---- calendar-home-set (list all calendars) ----
|
||||
|
||||
app.route({
|
||||
method: ['PROPFIND'],
|
||||
url: '/caldav/calendars/:principal/',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const depth = req.headers.depth ?? '0'
|
||||
const responses = [davResponse(calendarHomeHref(email), [
|
||||
propRaw('D:resourcetype', '<D:collection/>'),
|
||||
propText('D:displayname', 'Calendars'),
|
||||
].join('\n'))]
|
||||
|
||||
if (depth !== '0') {
|
||||
const calendars = await listCalendars(pool)
|
||||
for (const cal of calendars) {
|
||||
const ctag = await getCalendarCtag(pool, cal.id)
|
||||
responses.push(davResponse(calendarHref(email, cal.slug), [
|
||||
propRaw('D:resourcetype', '<D:collection/><C:calendar/>'),
|
||||
propText('D:displayname', cal.name),
|
||||
propRaw('C:supported-calendar-component-set', '<C:comp name="VEVENT"/>'),
|
||||
propText('CS:getctag', ctag),
|
||||
propText('ICAL:calendar-color', cal.color),
|
||||
].join('\n')))
|
||||
}
|
||||
}
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||
},
|
||||
})
|
||||
|
||||
// ---- a specific calendar collection: PROPFIND + REPORT ----
|
||||
|
||||
app.route({
|
||||
method: ['PROPFIND'],
|
||||
url: '/caldav/calendars/:principal/:slug/',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||
if (!cal) return reply.code(404).send()
|
||||
const depth = req.headers.depth ?? '0'
|
||||
|
||||
const ctag = await getCalendarCtag(pool, cal.id)
|
||||
const responses = [davResponse(calendarHref(email, cal.slug), [
|
||||
propRaw('D:resourcetype', '<D:collection/><C:calendar/>'),
|
||||
propText('D:displayname', cal.name),
|
||||
propRaw('C:supported-calendar-component-set', '<C:comp name="VEVENT"/>'),
|
||||
propText('CS:getctag', ctag),
|
||||
propText('ICAL:calendar-color', cal.color),
|
||||
].join('\n'))]
|
||||
|
||||
if (depth !== '0') {
|
||||
const events = await listEventsInCalendar(pool, cal.id)
|
||||
for (const ev of events) {
|
||||
responses.push(davResponse(eventHref(email, cal.slug, ev.uid), propText('D:getetag', etagFor(ev))))
|
||||
}
|
||||
}
|
||||
reply.code(207).header('Content-Type', 'application/xml; charset=utf-8').send(multistatus(responses.join('\n')))
|
||||
},
|
||||
})
|
||||
|
||||
app.route({
|
||||
method: ['REPORT'],
|
||||
url: '/caldav/calendars/:principal/:slug/',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||
if (!cal) return reply.code(404).send()
|
||||
|
||||
const doc = parseXml(req.body)
|
||||
const root = doc?.documentElement
|
||||
const rootName = root?.localName || root?.tagName || ''
|
||||
|
||||
if (rootName === 'sync-collection') return handleSyncCollection(reply, email, cal, doc)
|
||||
if (rootName === 'calendar-multiget') return handleMultiget(reply, email, cal, doc)
|
||||
return handleCalendarQuery(reply, email, cal, doc)
|
||||
},
|
||||
})
|
||||
|
||||
// MKCALENDAR — no CalDAV-side calendar creation in v1.
|
||||
app.route({
|
||||
method: ['MKCALENDAR'],
|
||||
url: '/caldav/calendars/:principal/:slug',
|
||||
handler: async (req, reply) => {
|
||||
reply.code(405).header('Allow', 'PROPFIND, REPORT, OPTIONS').send()
|
||||
},
|
||||
})
|
||||
|
||||
// ---- individual event resources: GET/HEAD/PUT/DELETE ----
|
||||
|
||||
app.route({
|
||||
method: ['GET', 'HEAD'],
|
||||
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||
handler: async (req, reply) => {
|
||||
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||
if (!cal) return reply.code(404).send()
|
||||
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '')
|
||||
const ev = await getFullEvent(pool, cal.id, uid)
|
||||
if (!ev) return reply.code(404).send()
|
||||
|
||||
reply.header('Content-Type', 'text/calendar; charset=utf-8').header('ETag', etagFor(ev))
|
||||
if (req.method === 'HEAD') return reply.code(200).send()
|
||||
return reply.send(eventToIcsString(ev, cal.name))
|
||||
},
|
||||
})
|
||||
|
||||
app.route({
|
||||
method: ['PUT'],
|
||||
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||
if (!cal) return reply.code(404).send()
|
||||
if (cal.is_system) return reply.code(403).send('Cannot write to a system calendar')
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = parseIcsEvent(req.body || '')
|
||||
} catch (err) {
|
||||
return reply.code(400).send(err.message)
|
||||
}
|
||||
|
||||
// The resource URL is the identity; fall back to the body's own UID
|
||||
// only if the client didn't send one in the URL (shouldn't happen).
|
||||
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '') || parsed.uid
|
||||
|
||||
const existing = await getFullEvent(pool, cal.id, uid)
|
||||
const ifMatch = req.headers['if-match']
|
||||
const ifNoneMatch = req.headers['if-none-match']
|
||||
|
||||
if (existing) {
|
||||
if (ifNoneMatch === '*') return reply.code(412).send()
|
||||
if (ifMatch && ifMatch !== etagFor(existing)) return reply.code(412).send()
|
||||
await updateEventFromCaldav(pool, existing.id, { ...parsed, uid }, email)
|
||||
} else {
|
||||
if (ifMatch) return reply.code(412).send()
|
||||
await createEventFromCaldav(pool, cal.id, { ...parsed, uid }, email)
|
||||
}
|
||||
|
||||
const full = await getFullEvent(pool, cal.id, uid)
|
||||
reply.code(existing ? 204 : 201).header('ETag', etagFor(full)).send()
|
||||
},
|
||||
})
|
||||
|
||||
app.route({
|
||||
method: ['DELETE'],
|
||||
url: '/caldav/calendars/:principal/:slug/:filename',
|
||||
handler: async (req, reply) => {
|
||||
const email = req.caldavUser.email
|
||||
const cal = await getCalendarBySlug(pool, req.params.slug)
|
||||
if (!cal) return reply.code(404).send()
|
||||
if (cal.is_system) return reply.code(403).send('Cannot write to a system calendar')
|
||||
|
||||
const uid = decodeURIComponent(req.params.filename).replace(/\.ics$/i, '')
|
||||
const existing = await getFullEvent(pool, cal.id, uid)
|
||||
if (!existing) return reply.code(404).send()
|
||||
|
||||
const ifMatch = req.headers['if-match']
|
||||
if (ifMatch && ifMatch !== etagFor(existing)) return reply.code(412).send()
|
||||
|
||||
await deleteEventFromCaldav(pool, existing.id, email)
|
||||
reply.code(204).send()
|
||||
},
|
||||
})
|
||||
}
|
||||
96
backend/src/routes/calendars.js
Normal file
96
backend/src/routes/calendars.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { logActivity } from '../lib/activity.js'
|
||||
|
||||
function slugify(name) {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'calendar'
|
||||
}
|
||||
|
||||
async function uniqueSlug(base) {
|
||||
let slug = base
|
||||
let n = 2
|
||||
while (true) {
|
||||
const { rows } = await pool.query('SELECT 1 FROM calendars WHERE slug = $1', [slug])
|
||||
if (!rows.length) return slug
|
||||
slug = `${base}-${n}`
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
export async function calendarRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/calendars
|
||||
app.get('/api/calendars', { preHandler: requireCap('view') }, async () => {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM calendars WHERE deleted_at IS NULL ORDER BY is_system ASC, name ASC`
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
// POST /api/calendars
|
||||
app.post('/api/calendars', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||
const { name, color } = req.body || {}
|
||||
if (!name || !color) return reply.status(400).send({ error: 'name and color required' })
|
||||
|
||||
const slug = await uniqueSlug(slugify(name))
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO calendars (slug, name, color, is_system, created_by)
|
||||
VALUES ($1, $2, $3, FALSE, $4) RETURNING *`,
|
||||
[slug, name, color, req.user.email]
|
||||
)
|
||||
const calendar = rows[0]
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'created', entityType: 'calendar',
|
||||
entityId: calendar.id, calendarId: calendar.id, summary: `Created calendar "${calendar.name}"`,
|
||||
})
|
||||
|
||||
return reply.status(201).send(calendar)
|
||||
})
|
||||
|
||||
// PATCH /api/calendars/:id
|
||||
app.patch('/api/calendars/:id', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const { rows: existing } = await pool.query('SELECT * FROM calendars WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Calendar not found' })
|
||||
if (existing[0].is_system) return reply.status(400).send({ error: 'Cannot edit a system calendar' })
|
||||
|
||||
const b = req.body || {}
|
||||
const name = b.name ?? existing[0].name
|
||||
const color = b.color ?? existing[0].color
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE calendars SET name = $1, color = $2 WHERE id = $3 RETURNING *`,
|
||||
[name, color, id]
|
||||
)
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'updated', entityType: 'calendar',
|
||||
entityId: id, calendarId: id, summary: `Updated calendar "${rows[0].name}"`,
|
||||
})
|
||||
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// DELETE /api/calendars/:id — soft delete
|
||||
app.delete('/api/calendars/:id', { preHandler: requireCap('manage_calendars') }, async (req, reply) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const { rows: existing } = await pool.query('SELECT * FROM calendars WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||
if (!existing.length) return reply.status(404).send({ error: 'Calendar not found' })
|
||||
if (existing[0].is_system) return reply.status(400).send({ error: 'Cannot delete a system calendar' })
|
||||
|
||||
await pool.query('UPDATE calendars SET deleted_at = NOW() WHERE id = $1', [id])
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: req.user.email, actorName: req.user.name, action: 'deleted', entityType: 'calendar',
|
||||
entityId: id, calendarId: id, summary: `Deleted calendar "${existing[0].name}"`,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
41
backend/src/routes/departments.js
Normal file
41
backend/src/routes/departments.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { getAllDepartments, getDepartmentsForEmail } from '../lib/workforce.js'
|
||||
import { queryEventSummaries } from './events.js'
|
||||
|
||||
export async function departmentRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
app.addHook('preHandler', requireCap('view'))
|
||||
|
||||
// GET /api/departments — all Workforce departments, for the event tagging picker
|
||||
app.get('/api/departments', async () => {
|
||||
try {
|
||||
const depts = await getAllDepartments()
|
||||
return depts.map(d => ({ id: String(d.id), name: d.name }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/me/departments
|
||||
app.get('/api/me/departments', async (req) => {
|
||||
const depts = await getDepartmentsForEmail(req.user.email)
|
||||
return depts.map(d => ({ id: String(d.id), name: d.name }))
|
||||
})
|
||||
|
||||
// GET /api/me/upcoming?days=7
|
||||
app.get('/api/me/upcoming', async (req) => {
|
||||
const days = Math.max(1, Math.min(parseInt(req.query?.days) || 7, 90))
|
||||
const today = new Date()
|
||||
const from = today.toISOString().slice(0, 10) + 'T00:00:00Z'
|
||||
const end = new Date(today.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
const to = end.toISOString().slice(0, 10) + 'T23:59:59Z'
|
||||
|
||||
const depts = await getDepartmentsForEmail(req.user.email)
|
||||
const mineDeptIds = depts.map(d => String(d.id))
|
||||
|
||||
return queryEventSummaries(pool, {
|
||||
from, to, mineEmail: req.user.email, mineDeptIds,
|
||||
})
|
||||
})
|
||||
}
|
||||
328
backend/src/routes/events.js
Normal file
328
backend/src/routes/events.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
import { randomUUID } from 'crypto'
|
||||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { pool } from '../db.js'
|
||||
import { logActivity } from '../lib/activity.js'
|
||||
import { getDepartmentsForEmail } from '../lib/workforce.js'
|
||||
|
||||
// Single source of truth for event mutation logic — both the Fastify route
|
||||
// handlers below AND lib/caldav-store.js import and call these so a change
|
||||
// made via CalDAV and a change made via the web UI go through identical
|
||||
// validation, join-table handling and activity logging (source differs).
|
||||
|
||||
// Column aliases here (department_names/assignee_names) match the frontend's
|
||||
// EventSummary type exactly — it flattens tags down to name arrays for list/
|
||||
// agenda views. EventDetail (see getEventDetail) extends EventSummary, so it
|
||||
// carries these same flat arrays *and* the richer {id,name}/{email,name}
|
||||
// object arrays for the edit form.
|
||||
const EVENT_SUMMARY_SELECT = `
|
||||
SELECT e.id, e.calendar_id, c.name AS calendar_name, c.color AS calendar_color,
|
||||
e.uid, e.title, e.location, e.start_at, e.end_at, e.all_day,
|
||||
COALESCE(d.department_names, '[]'::json) AS department_names,
|
||||
COALESCE(a.assignee_names, '[]'::json) AS assignee_names,
|
||||
COALESCE(att.attachment_count, 0)::int AS attachment_count
|
||||
FROM events e
|
||||
JOIN calendars c ON c.id = e.calendar_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(ed.department_name ORDER BY ed.department_name) AS department_names
|
||||
FROM event_departments ed WHERE ed.event_id = e.id
|
||||
) d ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT json_agg(COALESCE(ea.user_name, ea.user_email) ORDER BY COALESCE(ea.user_name, ea.user_email)) AS assignee_names
|
||||
FROM event_assignees ea WHERE ea.event_id = e.id
|
||||
) a ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS attachment_count FROM event_attachments att2 WHERE att2.event_id = e.id
|
||||
) att ON TRUE
|
||||
`
|
||||
|
||||
const DIFF_FIELDS = ['title', 'description', 'location', 'start_at', 'end_at', 'all_day']
|
||||
|
||||
function normalizeForDiff(v) {
|
||||
if (v == null) return v
|
||||
if (v instanceof Date) return v.toISOString()
|
||||
return v
|
||||
}
|
||||
|
||||
async function replaceDepartments(pool, eventId, departments) {
|
||||
await pool.query('DELETE FROM event_departments WHERE event_id = $1', [eventId])
|
||||
for (const d of departments) {
|
||||
if (!d?.id) continue
|
||||
await pool.query(
|
||||
`INSERT INTO event_departments (event_id, department_id, department_name)
|
||||
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`,
|
||||
[eventId, String(d.id), d.name || String(d.id)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceAssignees(pool, eventId, assignees) {
|
||||
await pool.query('DELETE FROM event_assignees WHERE event_id = $1', [eventId])
|
||||
for (const a of assignees) {
|
||||
if (!a?.email) continue
|
||||
await pool.query(
|
||||
`INSERT INTO event_assignees (event_id, user_email, user_name)
|
||||
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`,
|
||||
[eventId, a.email, a.name || a.email]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/events list query, also reused by GET /api/me/upcoming.
|
||||
export async function queryEventSummaries(pool, { from, to, calendarId, departmentId, mineEmail, mineDeptIds } = {}) {
|
||||
const clauses = ['e.deleted_at IS NULL']
|
||||
const vals = []
|
||||
const push = v => { vals.push(v); return `$${vals.length}` }
|
||||
|
||||
if (from) clauses.push(`e.end_at >= ${push(from)}`)
|
||||
if (to) clauses.push(`e.start_at <= ${push(to)}`)
|
||||
if (calendarId) clauses.push(`e.calendar_id = ${push(calendarId)}`)
|
||||
if (departmentId) {
|
||||
clauses.push(`EXISTS (SELECT 1 FROM event_departments ed WHERE ed.event_id = e.id AND ed.department_id = ${push(String(departmentId))})`)
|
||||
}
|
||||
if (mineEmail) {
|
||||
const deptIds = mineDeptIds || []
|
||||
if (deptIds.length) {
|
||||
clauses.push(`(
|
||||
EXISTS (SELECT 1 FROM event_assignees ea WHERE ea.event_id = e.id AND ea.user_email = ${push(mineEmail)})
|
||||
OR EXISTS (SELECT 1 FROM event_departments ed WHERE ed.event_id = e.id AND ed.department_id = ANY(${push(deptIds)}))
|
||||
)`)
|
||||
} else {
|
||||
clauses.push(`EXISTS (SELECT 1 FROM event_assignees ea WHERE ea.event_id = e.id AND ea.user_email = ${push(mineEmail)})`)
|
||||
}
|
||||
}
|
||||
|
||||
const sql = `${EVENT_SUMMARY_SELECT} WHERE ${clauses.join(' AND ')} ORDER BY e.start_at ASC`
|
||||
const { rows } = await pool.query(sql, vals)
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function getEventDetail(pool, id) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT e.*, c.id AS cal_id, c.name AS cal_name, c.color AS cal_color, c.is_system AS cal_is_system
|
||||
FROM events e JOIN calendars c ON c.id = e.calendar_id
|
||||
WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
if (!rows.length) return null
|
||||
const row = rows[0]
|
||||
|
||||
const [{ rows: departments }, { rows: assignees }, { rows: attachments }] = await Promise.all([
|
||||
pool.query('SELECT department_id AS id, department_name AS name FROM event_departments WHERE event_id = $1 ORDER BY department_name', [id]),
|
||||
pool.query('SELECT user_email AS email, user_name AS name FROM event_assignees WHERE event_id = $1 ORDER BY user_name', [id]),
|
||||
pool.query('SELECT id, filename, mime_type, size_bytes, stored_path FROM event_attachments WHERE event_id = $1 ORDER BY created_at', [id]),
|
||||
])
|
||||
|
||||
return {
|
||||
// EventSummary fields (EventDetail extends EventSummary on the frontend)
|
||||
id: row.id,
|
||||
calendar_id: row.calendar_id,
|
||||
calendar_name: row.cal_name,
|
||||
calendar_color: row.cal_color,
|
||||
uid: row.uid,
|
||||
title: row.title,
|
||||
location: row.location,
|
||||
start_at: row.start_at,
|
||||
end_at: row.end_at,
|
||||
all_day: row.all_day,
|
||||
department_names: departments.map(d => d.name),
|
||||
assignee_names: assignees.map(a => a.name),
|
||||
attachment_count: attachments.length,
|
||||
// EventDetail-only fields
|
||||
description: row.description,
|
||||
sequence: row.sequence,
|
||||
created_by: row.created_by,
|
||||
created_by_name: row.created_by_name,
|
||||
updated_at: row.updated_at,
|
||||
created_at: row.created_at,
|
||||
departments,
|
||||
assignees,
|
||||
attachments: attachments.map(a => ({
|
||||
id: a.id, filename: a.filename, mime_type: a.mime_type, size_bytes: a.size_bytes,
|
||||
url: `/api/uploads/${a.stored_path}`,
|
||||
})),
|
||||
calendar: { id: row.cal_id, name: row.cal_name, color: row.cal_color, is_system: row.cal_is_system },
|
||||
}
|
||||
}
|
||||
|
||||
// data: { calendar_id, title, description?, location?, start_at, end_at, all_day,
|
||||
// departments?: [{id,name}], assignees?: [{email,name}], uid? }
|
||||
// actor: { email, name }
|
||||
export async function createEvent(pool, data, actor, source = 'web') {
|
||||
const uid = data.uid || `${randomUUID()}@calendar.hotelnumberfour.com`
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO events (calendar_id, uid, title, description, location, start_at, end_at, all_day, created_by, created_by_name)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING id`,
|
||||
[
|
||||
data.calendar_id,
|
||||
uid,
|
||||
data.title,
|
||||
data.description || null,
|
||||
data.location || null,
|
||||
data.start_at,
|
||||
data.end_at,
|
||||
!!data.all_day,
|
||||
actor.email || null,
|
||||
actor.name || null,
|
||||
]
|
||||
)
|
||||
const eventId = rows[0].id
|
||||
|
||||
if (data.departments !== undefined) await replaceDepartments(pool, eventId, data.departments)
|
||||
if (data.assignees !== undefined) await replaceAssignees(pool, eventId, data.assignees)
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: actor.email, actorName: actor.name, action: 'created', entityType: 'event',
|
||||
entityId: eventId, calendarId: data.calendar_id, summary: `Created event "${data.title}"`, source,
|
||||
})
|
||||
|
||||
return getEventDetail(pool, eventId)
|
||||
}
|
||||
|
||||
// data: partial — same shape as createEvent. Fields omitted entirely are left
|
||||
// unchanged (this matters for CalDAV updates, which never send departments/
|
||||
// assignees and must not wipe them).
|
||||
export async function updateEvent(pool, id, data, actor, source = 'web') {
|
||||
const { rows: existingRows } = await pool.query('SELECT * FROM events WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||
if (!existingRows.length) return null
|
||||
const existing = existingRows[0]
|
||||
|
||||
const merged = {
|
||||
title: data.title ?? existing.title,
|
||||
description: data.description !== undefined ? data.description : existing.description,
|
||||
location: data.location !== undefined ? data.location : existing.location,
|
||||
start_at: data.start_at ?? existing.start_at,
|
||||
end_at: data.end_at ?? existing.end_at,
|
||||
all_day: data.all_day !== undefined ? !!data.all_day : existing.all_day,
|
||||
calendar_id: data.calendar_id ?? existing.calendar_id,
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE events SET
|
||||
title = $1, description = $2, location = $3, start_at = $4, end_at = $5, all_day = $6,
|
||||
calendar_id = $7, sequence = sequence + 1, updated_at = NOW()
|
||||
WHERE id = $8`,
|
||||
[merged.title, merged.description, merged.location, merged.start_at, merged.end_at, merged.all_day, merged.calendar_id, id]
|
||||
)
|
||||
|
||||
if (data.departments !== undefined) await replaceDepartments(pool, id, data.departments)
|
||||
if (data.assignees !== undefined) await replaceAssignees(pool, id, data.assignees)
|
||||
|
||||
const changes = {}
|
||||
for (const f of DIFF_FIELDS) {
|
||||
if (data[f] === undefined) continue
|
||||
const before = normalizeForDiff(existing[f])
|
||||
const after = normalizeForDiff(data[f])
|
||||
if (String(before) !== String(after)) changes[f] = { from: before, to: after }
|
||||
}
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: actor.email, actorName: actor.name, action: 'updated', entityType: 'event',
|
||||
entityId: id, calendarId: merged.calendar_id, summary: `Updated event "${merged.title}"`,
|
||||
details: Object.keys(changes).length ? changes : null, source,
|
||||
})
|
||||
|
||||
return getEventDetail(pool, id)
|
||||
}
|
||||
|
||||
export async function deleteEvent(pool, id, actor, source = 'web') {
|
||||
const { rows } = await pool.query('SELECT id, title, calendar_id FROM events WHERE id = $1 AND deleted_at IS NULL', [id])
|
||||
if (!rows.length) return false
|
||||
const event = rows[0]
|
||||
|
||||
await pool.query('UPDATE events SET deleted_at = NOW() WHERE id = $1', [id])
|
||||
|
||||
await logActivity(pool, {
|
||||
actorEmail: actor.email, actorName: actor.name, action: 'deleted', entityType: 'event',
|
||||
entityId: id, calendarId: event.calendar_id, summary: `Deleted event "${event.title}"`, source,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function eventRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// GET /api/events?from=&to=&calendar_id=&department_id=&mine=true
|
||||
app.get('/api/events', { preHandler: requireCap('view') }, async (req) => {
|
||||
const q = req.query || {}
|
||||
const from = q.from ? `${q.from}T00:00:00Z` : null
|
||||
const to = q.to ? `${q.to}T23:59:59Z` : null
|
||||
|
||||
let mineDeptIds = []
|
||||
if (q.mine === 'true') {
|
||||
const depts = await getDepartmentsForEmail(req.user.email)
|
||||
mineDeptIds = depts.map(d => String(d.id))
|
||||
}
|
||||
|
||||
return queryEventSummaries(pool, {
|
||||
from,
|
||||
to,
|
||||
calendarId: q.calendar_id ? parseInt(q.calendar_id) : null,
|
||||
departmentId: q.department_id || null,
|
||||
mineEmail: q.mine === 'true' ? req.user.email : null,
|
||||
mineDeptIds,
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/events/:id — full detail
|
||||
app.get('/api/events/:id', { preHandler: requireCap('view') }, async (req, reply) => {
|
||||
const detail = await getEventDetail(pool, parseInt(req.params.id))
|
||||
if (!detail) return reply.status(404).send({ error: 'Event not found' })
|
||||
return detail
|
||||
})
|
||||
|
||||
// POST /api/events
|
||||
app.post('/api/events', { preHandler: requireCap('create') }, async (req, reply) => {
|
||||
const b = req.body || {}
|
||||
if (!b.calendar_id || !b.title || !b.start_at || !b.end_at) {
|
||||
return reply.status(400).send({ error: 'calendar_id, title, start_at and end_at are required' })
|
||||
}
|
||||
const { rows: cal } = await pool.query('SELECT id, is_system FROM calendars WHERE id = $1 AND deleted_at IS NULL', [b.calendar_id])
|
||||
if (!cal.length) return reply.status(400).send({ error: 'Unknown calendar' })
|
||||
if (cal[0].is_system) return reply.status(400).send({ error: 'Cannot create events directly on a system calendar' })
|
||||
|
||||
const detail = await createEvent(pool, b, { email: req.user.email, name: req.user.name })
|
||||
return reply.status(201).send(detail)
|
||||
})
|
||||
|
||||
// PATCH /api/events/:id
|
||||
app.patch('/api/events/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const b = req.body || {}
|
||||
|
||||
// A system-calendar event (e.g. a bank holiday) isn't user-editable —
|
||||
// enforced here too, not just in the UI, and matching what the CalDAV
|
||||
// PUT handler already does. Check both the event's current calendar and
|
||||
// (if the request tries to move it) the target calendar.
|
||||
const { rows: current } = await pool.query(
|
||||
`SELECT c.is_system FROM events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
if (!current.length) return reply.status(404).send({ error: 'Event not found' })
|
||||
if (current[0].is_system) return reply.status(400).send({ error: 'Cannot edit an event on a system calendar' })
|
||||
|
||||
if (b.calendar_id) {
|
||||
const { rows: cal } = await pool.query('SELECT id, is_system FROM calendars WHERE id = $1 AND deleted_at IS NULL', [b.calendar_id])
|
||||
if (!cal.length) return reply.status(400).send({ error: 'Unknown calendar' })
|
||||
if (cal[0].is_system) return reply.status(400).send({ error: 'Cannot move an event onto a system calendar' })
|
||||
}
|
||||
const detail = await updateEvent(pool, id, b, { email: req.user.email, name: req.user.name })
|
||||
if (!detail) return reply.status(404).send({ error: 'Event not found' })
|
||||
return detail
|
||||
})
|
||||
|
||||
// DELETE /api/events/:id — soft delete
|
||||
app.delete('/api/events/:id', { preHandler: requireCap('edit') }, async (req, reply) => {
|
||||
const id = parseInt(req.params.id)
|
||||
const { rows: current } = await pool.query(
|
||||
`SELECT c.is_system FROM events e JOIN calendars c ON c.id = e.calendar_id WHERE e.id = $1 AND e.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
if (!current.length) return reply.status(404).send({ error: 'Event not found' })
|
||||
if (current[0].is_system) return reply.status(400).send({ error: 'Cannot delete an event on a system calendar' })
|
||||
|
||||
const ok = await deleteEvent(pool, id, { email: req.user.email, name: req.user.name })
|
||||
if (!ok) return reply.status(404).send({ error: 'Event not found' })
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue