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>
106 lines
3.9 KiB
JavaScript
106 lines
3.9 KiB
JavaScript
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`
|
|
)
|
|
}
|