diff --git a/backend/package-lock.json b/backend/package-lock.json index be69c99..99b4bf0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,7 +18,10 @@ "ical.js": "^2.1.0", "ics": "^3.8.1", "jose": "^5.9.6", - "pg": "^8.13.1" + "nodemailer": "^9.0.3", + "pg": "^8.13.1", + "rrule": "^2.8.1", + "web-push": "^3.6.7" } }, "node_modules/@fastify/accept-negotiator": { @@ -215,6 +218,15 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", @@ -288,6 +300,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -325,6 +349,12 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", @@ -334,6 +364,12 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -346,26 +382,6 @@ "node": ">= 0.6" } }, - "node_modules/content-disposition/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -398,6 +414,23 @@ "node": ">= 8" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -413,6 +446,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -605,6 +647,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -621,6 +672,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ical.js": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", @@ -707,6 +771,27 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -736,6 +821,12 @@ "node": ">=10.0.0" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -751,6 +842,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -769,6 +869,12 @@ "obliterator": "^2.0.1" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -787,6 +893,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nodemailer": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -1088,12 +1203,41 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/runes2": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/runes2/-/runes2-1.1.4.tgz", "integrity": "sha512-LNPnEDPOOU4ehF71m5JoQyzT2yxwD6ZreFJ7MxZUAoMKNMY1XrAo60H1CUoX5ncSm0rIuKlqn9JZNRrRkNou2g==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", @@ -1112,6 +1256,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/secure-json-parse": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", @@ -1346,6 +1496,12 @@ "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", @@ -1358,6 +1514,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index dfa7ec5..6b5446c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,9 @@ "ical.js": "^2.1.0", "ics": "^3.8.1", "jose": "^5.9.6", - "pg": "^8.13.1" + "nodemailer": "^9.0.3", + "pg": "^8.13.1", + "rrule": "^2.8.1", + "web-push": "^3.6.7" } } diff --git a/backend/src/db.js b/backend/src/db.js index 3013b28..1654a05 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -16,6 +16,12 @@ export async function initDb() { created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS events ( id SERIAL PRIMARY KEY, calendar_id INT NOT NULL REFERENCES calendars(id), @@ -26,6 +32,7 @@ export async function initDb() { start_at TIMESTAMPTZ NOT NULL, end_at TIMESTAMPTZ NOT NULL, all_day BOOLEAN NOT NULL DEFAULT FALSE, + rrule TEXT, -- RFC 5545 RRULE value (no "RRULE:" prefix); non-null = recurring master sequence INT NOT NULL DEFAULT 0, created_by TEXT, -- email created_by_name TEXT, @@ -37,6 +44,47 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS events_start_idx ON events (start_at); CREATE INDEX IF NOT EXISTS events_deleted_idx ON events (deleted_at); + -- Per-occurrence overrides/cancellations for a recurring master event. + -- occurrence_start is the ORIGINAL (un-shifted) RRULE-computed occurrence + -- time — RECURRENCE-ID semantics. NULL on an override field = inherit + -- from the master. Deliberately does not cover departments/assignees/ + -- attachments — every occurrence of a series shows the master's tags. + CREATE TABLE IF NOT EXISTS event_exceptions ( + id SERIAL PRIMARY KEY, + master_event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + occurrence_start TIMESTAMPTZ NOT NULL, + is_cancelled BOOLEAN NOT NULL DEFAULT FALSE, + title TEXT, + description TEXT, + location TEXT, + start_at TIMESTAMPTZ, + end_at TIMESTAMPTZ, + all_day BOOLEAN, + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (master_event_id, occurrence_start) + ); + + -- Dedupes reminder sends across the 5-minute scheduler poll interval. + CREATE TABLE IF NOT EXISTS event_reminders_sent ( + event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + occurrence_start TIMESTAMPTZ NOT NULL, + user_email TEXT NOT NULL, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (event_id, occurrence_start, user_email) + ); + + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id SERIAL PRIMARY KEY, + user_email TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS push_subs_email_idx ON push_subscriptions (user_email); + CREATE TABLE IF NOT EXISTS event_departments ( event_id INT NOT NULL REFERENCES events(id) ON DELETE CASCADE, department_id TEXT NOT NULL, @@ -89,6 +137,9 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS activity_log_entity_idx ON activity_log (entity_type, entity_id); `) + // Incremental migration — idempotent, for deploys that predate recurrence support. + await pool.query(`ALTER TABLE events ADD COLUMN IF NOT EXISTS rrule TEXT`) + await seedDefaults() } @@ -103,4 +154,21 @@ async function seedDefaults() { VALUES ('general', 'All Staff', '#c9a84c', FALSE) ON CONFLICT (slug) DO NOTHING` ) + + const defaults = { + notify_on_assignment: true, + notify_reminders: true, + reminder_minutes_before: 60, + } + for (const [key, value] of Object.entries(defaults)) { + await pool.query( + `INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + [key, JSON.stringify(value)] + ) + } +} + +export async function getConfig() { + const { rows } = await pool.query('SELECT key, value FROM config') + return Object.fromEntries(rows.map(r => [r.key, r.value])) } diff --git a/backend/src/lib/config-cache.js b/backend/src/lib/config-cache.js new file mode 100644 index 0000000..63aa161 --- /dev/null +++ b/backend/src/lib/config-cache.js @@ -0,0 +1,17 @@ +// Short-TTL cache around db.js's getConfig(), so the assignment-notification +// check on every event write (routes/events.js) and the 5-minute reminder +// poll (lib/reminders.js) don't each round-trip the config table on every +// call. Mirrors maintenance/backend/src/lib/mailer.js's SMTP-config cache +// pattern, applied to the whole config table instead of just SMTP settings. +import { getConfig } from '../db.js' + +const TTL_MS = 90 * 1000 + +let _cache = null // { value, expiresAt } + +export async function getCachedConfig() { + if (_cache && Date.now() < _cache.expiresAt) return _cache.value + const value = await getConfig() + _cache = { value, expiresAt: Date.now() + TTL_MS } + return value +} diff --git a/backend/src/lib/mailer.js b/backend/src/lib/mailer.js new file mode 100644 index 0000000..9c4f8db --- /dev/null +++ b/backend/src/lib/mailer.js @@ -0,0 +1,89 @@ +// Settings-fetched SMTP mailer — copied from maintenance/backend/src/lib/mailer.js's +// pattern (5-minute cached SMTP config from the central settings app, lazily +// built nodemailer transport, fire-and-forget send()). +import nodemailer from 'nodemailer' + +const SETTINGS_URL = process.env.SETTINGS_URL || '' +const SETTINGS_SECRET = process.env.SETTINGS_SECRET || '' + +let _smtpCache = null // { config, expires_at } +let _transporter = null + +async function getSmtpConfig() { + if (_smtpCache && Date.now() < _smtpCache.expires_at) return _smtpCache.config + const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/smtp`, { + headers: { Authorization: `Bearer ${SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Failed to fetch SMTP config from settings: ${res.status}`) + const config = await res.json() + if (!config.host) throw new Error('SMTP not configured in settings') + _smtpCache = { config, expires_at: Date.now() + 5 * 60_000 } + _transporter = null + return config +} + +async function getTransporter() { + if (_transporter) return _transporter + const config = await getSmtpConfig() + const port = parseInt(config.port || '587') + _transporter = nodemailer.createTransport({ + host: config.host, + port, + secure: port === 465, + auth: config.user ? { user: config.user, pass: config.pass } : undefined, + }) + return _transporter +} + +const HOTEL_NAME = process.env.VITE_HOTEL_NAME || 'Hotel' + +// Fire-and-forget: email failure must never block an event write. +async function send(to, subject, text) { + if (!to) return + try { + const config = await getSmtpConfig() + const transport = await getTransporter() + await transport.sendMail({ + from: config.from || `"${HOTEL_NAME} Calendar" `, + to, + subject, + text, + }) + } catch (err) { + console.error(`Calendar mail to ${to} failed: ${err.message}`) + } +} + +function fmtDateTime(d, allDay) { + const dt = new Date(d) + return allDay + ? dt.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) + : dt.toLocaleString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) +} + +function eventSummary(event) { + const lines = [ + `Event: ${event.title}`, + `When: ${fmtDateTime(event.start_at, event.all_day)} – ${fmtDateTime(event.end_at, event.all_day)}`, + ] + if (event.location) lines.push(`Where: ${event.location}`) + if (event.description) lines.push('', event.description) + return lines.join('\n') +} + +export function notifyAssignment(event, toEmail, toName) { + return send( + toEmail, + `[Calendar] Added to: ${event.title}`, + `You've been added to an event.\n\n${eventSummary(event)}` + ) +} + +export function notifyReminder(event, toEmail) { + return send( + toEmail, + `[Calendar] Starting soon: ${event.title}`, + `An event you're assigned to is starting soon.\n\n${eventSummary(event)}` + ) +} diff --git a/backend/src/lib/push.js b/backend/src/lib/push.js new file mode 100644 index 0000000..887c983 --- /dev/null +++ b/backend/src/lib/push.js @@ -0,0 +1,81 @@ +// Web push — copied from maintenance/backend/src/lib/push.js's pattern: +// VAPID keys auto-generated into the config table on first boot (or taken +// from env vars if set), subscriptions in push_subscriptions, expired/invalid +// subscriptions cleaned up on 404/410 from the push service. +import webPush from 'web-push' +import { pool } from '../db.js' + +let _publicKey = null + +export async function ensureVapid() { + if (_publicKey) return _publicKey + + let publicKey = process.env.VAPID_PUBLIC_KEY + let privateKey = process.env.VAPID_PRIVATE_KEY + const subject = process.env.VAPID_SUBJECT || 'mailto:noreply@localhost' + + if (!publicKey || !privateKey) { + const { rows } = await pool.query("SELECT value FROM config WHERE key = 'vapid_keys'") + if (rows.length && rows[0].value?.publicKey) { + publicKey = rows[0].value.publicKey + privateKey = rows[0].value.privateKey + } else { + const keys = webPush.generateVAPIDKeys() + publicKey = keys.publicKey + privateKey = keys.privateKey + await pool.query( + "INSERT INTO config (key, value) VALUES ('vapid_keys', $1) ON CONFLICT (key) DO UPDATE SET value = $1", + [JSON.stringify({ publicKey, privateKey })] + ) + } + } + + webPush.setVapidDetails(subject, publicKey, privateKey) + _publicKey = publicKey + return publicKey +} + +export async function sendPushToUser(userEmail, payload) { + await ensureVapid() + const { rows } = await pool.query( + 'SELECT * FROM push_subscriptions WHERE user_email = $1', + [userEmail] + ) + if (!rows.length) return + + const results = await Promise.allSettled( + rows.map(sub => + webPush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload) + ) + ) + ) + + for (let i = 0; i < results.length; i++) { + const r = results[i] + if (r.status === 'rejected' && [404, 410].includes(r.reason?.statusCode)) { + await pool.query('DELETE FROM push_subscriptions WHERE id = $1', [rows[i].id]).catch(() => {}) + } + } +} + +export function notifyAssignmentPush(event, toEmail) { + if (!toEmail) return Promise.resolve() + return sendPushToUser(toEmail, { + title: `Added to: ${event.title}`, + body: event.location || 'Calendar event', + url: '/calendar/', + tag: `calendar-event-${event.id}`, + }).catch(err => console.error('[push] notifyAssignmentPush failed:', err.message)) +} + +export function notifyReminderPush(event, toEmail) { + if (!toEmail) return Promise.resolve() + return sendPushToUser(toEmail, { + title: `Starting soon: ${event.title}`, + body: event.location || 'Calendar event', + url: '/calendar/', + tag: `calendar-event-${event.id}`, + }).catch(err => console.error('[push] notifyReminderPush failed:', err.message)) +} diff --git a/backend/src/lib/recurrence.js b/backend/src/lib/recurrence.js new file mode 100644 index 0000000..6a9b18e --- /dev/null +++ b/backend/src/lib/recurrence.js @@ -0,0 +1,122 @@ +// Recurrence math for RRULE-based recurring events. All date arithmetic goes +// through the `rrule` npm package — nothing here hand-rolls RRULE expansion. +// +// Timezone note: like the rest of this app (see lib/bank-holidays.js, +// lib/caldav-store.js), timestamps are stored/compared as plain UTC instants +// and treated as the hotel's "local" wall-clock time — there's no separate +// per-event timezone concept. `rrule` treats dates the same way (its docs +// call this "floating"/UTC time — see the README's "Use UTC dates" section): +// as long as every Date we hand it and read back from it is real UTC +// (which is what `pg` already gives us for TIMESTAMPTZ columns), expansion +// is internally consistent with how the rest of the app reads/writes time. +import { RRule } from 'rrule' + +// event_exceptions rows only override title/description/location/timing/ +// cancellation — departments/assignees/attachments always come from the +// master (see db.js's event_exceptions comment / the recurring-events scope +// note in the spec this was built against). +const OVERRIDE_FIELDS = ['title', 'description', 'location', 'start_at', 'end_at', 'all_day'] + +function buildRRule(master) { + // RRule.fromString() alone has no way to inject a dtstart that isn't + // embedded in the string itself, so parse the bare RRULE value (our + // `rrule` column never stores a DTSTART line — see db.js) and set dtstart + // explicitly from the master's own start_at, per spec. + const opts = RRule.parseString(master.rrule) + opts.dtstart = new Date(master.start_at) + return new RRule(opts) +} + +function occurrenceKey(d) { + return new Date(d).toISOString() +} + +function indexExceptions(exceptions) { + const map = new Map() + for (const ex of exceptions || []) map.set(occurrenceKey(ex.occurrence_start), ex) + return map +} + +// Merges an exception's non-null override fields over the master's fields +// for one occurrence. `occDate` is the original (un-shifted) RRULE-computed +// occurrence date. +function effectiveFields(master, ex, occDate, durationMs) { + const base = { + title: master.title, + description: master.description, + location: master.location, + start_at: occDate, + end_at: new Date(occDate.getTime() + durationMs), + all_day: master.all_day, + } + if (!ex) return base + for (const f of OVERRIDE_FIELDS) { + if (ex[f] === null || ex[f] === undefined) continue + base[f] = f === 'start_at' || f === 'end_at' ? new Date(ex[f]) : ex[f] + } + return base +} + +// Expands a recurring master into virtual occurrence objects overlapping +// [rangeStart, rangeEnd], shaped like queryEventSummaries's rows. +// `master` needs: id, calendar_id, calendar_name, calendar_color, uid, +// title, description, location, start_at, end_at, all_day, rrule, +// department_names, assignee_names, attachment_count. +export function expandOccurrences(master, exceptions, rangeStart, rangeEnd) { + if (!master?.rrule) return [] + const rule = buildRRule(master) + const durationMs = new Date(master.end_at).getTime() - new Date(master.start_at).getTime() + const exByKey = indexExceptions(exceptions) + + const occDates = rule.between(new Date(rangeStart), new Date(rangeEnd), true) + + const results = [] + for (const occDate of occDates) { + const key = occurrenceKey(occDate) + const ex = exByKey.get(key) + if (ex?.is_cancelled) continue + + const fields = effectiveFields(master, ex, occDate, durationMs) + results.push({ + id: master.id, + calendar_id: master.calendar_id, + calendar_name: master.calendar_name, + calendar_color: master.calendar_color, + uid: master.uid, + title: fields.title, + location: fields.location, + start_at: fields.start_at, + end_at: fields.end_at, + all_day: fields.all_day, + department_names: master.department_names, + assignee_names: master.assignee_names, + attachment_count: master.attachment_count, + is_recurring: true, + occurrence_start: key, + }) + } + return results +} + +// Resolves a single occurrence's effective fields (for the occurrence-scoped +// detail GET). Returns null if the occurrence is cancelled, or isn't +// actually a real RRULE occurrence (validated against the RRULE itself, +// not trusted blindly from the caller). +export function getEffectiveOccurrence(master, exceptions, occurrenceStart) { + if (!master?.rrule) return null + const target = new Date(occurrenceStart) + if (isNaN(target.getTime())) return null + + const rule = buildRRule(master) + const matches = rule.between(target, target, true) + if (!matches.length) return null + + const exByKey = indexExceptions(exceptions) + const key = occurrenceKey(target) + const ex = exByKey.get(key) + if (ex?.is_cancelled) return null + + const durationMs = new Date(master.end_at).getTime() - new Date(master.start_at).getTime() + const fields = effectiveFields(master, ex, target, durationMs) + return { ...fields, is_exception: !!ex } +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..27db620 --- /dev/null +++ b/backend/src/routes/config.js @@ -0,0 +1,28 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' + +export async function configRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/config — all config keys as a flat object + app.get('/api/config', { preHandler: requireCap('view') }, async () => { + const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key') + return Object.fromEntries(rows.map(r => [r.key, r.value])) + }) + + // PATCH /api/config/:key — update a single config key. Gated on 'admin' + // (same cap that already gates the activity log) rather than a new + // capability — nothing about notification settings warrants a separate one. + app.patch('/api/config/:key', { preHandler: requireCap('admin') }, async (req, reply) => { + const { key } = req.params + const { value } = req.body || {} + if (value === undefined) return reply.status(400).send({ error: 'value required' }) + + await pool.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)] + ) + return { ok: true } + }) +} diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js new file mode 100644 index 0000000..21b2f33 --- /dev/null +++ b/backend/src/routes/push.js @@ -0,0 +1,36 @@ +import { requireAuth } from '../auth.js' +import { pool } from '../db.js' +import { ensureVapid } from '../lib/push.js' + +export async function pushRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/push/vapid-key', async () => { + const publicKey = await ensureVapid() + return { publicKey } + }) + + app.post('/api/push/subscribe', async (req, reply) => { + const { endpoint, keys } = req.body || {} + if (!endpoint || !keys?.p256dh || !keys?.auth) { + return reply.status(400).send({ error: 'endpoint and keys (p256dh, auth) required' }) + } + await pool.query( + `INSERT INTO push_subscriptions (user_email, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (endpoint) DO UPDATE SET user_email = $1, p256dh = $3, auth = $4, updated_at = NOW()`, + [req.user.email, endpoint, keys.p256dh, keys.auth] + ) + return { ok: true } + }) + + app.delete('/api/push/unsubscribe', async (req, reply) => { + const { endpoint } = req.body || {} + if (endpoint) { + await pool.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [endpoint]) + } else { + await pool.query('DELETE FROM push_subscriptions WHERE user_email = $1', [req.user.email]) + } + return { ok: true } + }) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 71f4ca9..e5e33cf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "rrule": "^2.8.1" }, "devDependencies": { "@types/react": "^18.3.1", @@ -5047,6 +5048,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -5548,6 +5558,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1cd722a..d793329 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "rrule": "^2.8.1" }, "devDependencies": { "@types/react": "^18.3.1", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5ddda77..2acdba4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,7 @@ import type { Calendar, EventSummary, EventDetail, EventAttachment, ActivityLogEntry, Department, AuthUser, CaldavCredential, CaldavCredentialCreated, + AppConfig, } from './types' const BASE = '/calendar/api' @@ -53,8 +54,11 @@ export function fetchEvents(filters: EventFilters = {}): Promise const qs = params.toString() return request(`/events${qs ? `?${qs}` : ''}`) } -export function fetchEvent(id: number): Promise { - return request(`/events/${id}`) +// occurrence_start: pass when fetching the effective (merged master+override) +// fields for one occurrence of a recurring series rather than the master row. +export function fetchEvent(id: number, occurrence_start?: string): Promise { + const qs = occurrence_start ? `?occurrence_start=${encodeURIComponent(occurrence_start)}` : '' + return request(`/events/${id}${qs}`) } export interface EventBody { calendar_id: number @@ -66,15 +70,32 @@ export interface EventBody { all_day: boolean departments?: { id: string; name: string }[] assignees?: { email: string; name: string }[] + // RFC 5545 RRULE property value, e.g. "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=10" — + // bare value, no "RRULE:" prefix and no DTSTART line. + rrule?: string | null +} +export interface EventScopeOptions { + // Present only when scope is 'this' — identifies which occurrence. + occurrence_start?: string + // 'this' edits/deletes just that occurrence (creates/updates a per-occurrence + // override); 'all' (default) edits/deletes the whole series (the master row). + scope?: 'this' | 'all' } export function createEvent(body: EventBody): Promise { return request('/events', { method: 'POST', body: JSON.stringify(body) }) } -export function updateEvent(id: number, body: Partial): Promise { - return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +export function updateEvent(id: number, body: Partial, opts: EventScopeOptions = {}): Promise { + const payload: Partial & EventScopeOptions = { ...body } + if (opts.occurrence_start) payload.occurrence_start = opts.occurrence_start + if (opts.scope) payload.scope = opts.scope + return request(`/events/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }) } -export function deleteEvent(id: number): Promise<{ ok: boolean }> { - return request(`/events/${id}`, { method: 'DELETE' }) +export function deleteEvent(id: number, opts: EventScopeOptions = {}): Promise<{ ok: boolean }> { + const params = new URLSearchParams() + if (opts.occurrence_start) params.set('occurrence_start', opts.occurrence_start) + if (opts.scope) params.set('scope', opts.scope) + const qs = params.toString() + return request(`/events/${id}${qs ? `?${qs}` : ''}`, { method: 'DELETE' }) } // Attachments — multipart, so no JSON content-type header @@ -141,3 +162,22 @@ export async function fetchAssignableUsers(): Promise { if (!res.ok) throw new Error(`Failed to load users: ${res.status}`) return res.json() } + +// App config — notification settings, admin-editable +export function fetchConfig(): Promise { + return request('/config') +} +export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> { + return request(`/config/${key}`, { method: 'PATCH', body: JSON.stringify({ value }) }) +} + +// Push notifications +export function fetchVapidKey(): Promise<{ publicKey: string }> { + return request('/push/vapid-key') +} +export function subscribePush(subscription: PushSubscriptionJSON): Promise<{ ok: boolean }> { + return request('/push/subscribe', { method: 'POST', body: JSON.stringify(subscription) }) +} +export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> { + return request('/push/unsubscribe', { method: 'DELETE', body: JSON.stringify(endpoint ? { endpoint } : {}) }) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 467ebcc..6c9377f 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, createContext, useContext } from 'react' import type { User } from '../types' +import { usePushSubscription } from '../hooks/usePushSubscription' interface AuthCtx { user: User } const Ctx = createContext(null) @@ -10,6 +11,11 @@ export function useAuth() { return ctx } +function PushSubscriber({ user }: { user: User }) { + usePushSubscription(user) + return null +} + export default function AuthGate({ children }: { children: React.ReactNode }) { const [user, setUser] = useState(null) @@ -39,6 +45,7 @@ export default function AuthGate({ children }: { children: React.ReactNode }) { return ( + {children} ) diff --git a/frontend/src/components/EventForm.tsx b/frontend/src/components/EventForm.tsx index 3816d29..bf4fe1d 100644 --- a/frontend/src/components/EventForm.tsx +++ b/frontend/src/components/EventForm.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { X, Trash2, ChevronDown, ChevronRight, Lock } from 'lucide-react' +import { X, Trash2, ChevronDown, ChevronRight, Lock, Repeat } from 'lucide-react' import type { Calendar, Department, EventAttachment, AuthUser, ActivityLogEntry } from '../types' import { can } from '../types' import { useAuth } from './AuthGate' @@ -9,6 +9,7 @@ import { } from '../api' import type { EventBody } from '../api' import AttachmentList from './AttachmentList' +import RecurrenceEditor from './RecurrenceEditor' import { toISODate, toTimeInput } from '../dateUtils' interface Assignee { email: string; name: string } @@ -16,14 +17,23 @@ interface Assignee { email: string; name: string } interface EventFormProps { eventId?: number initialDate?: Date + // Set (by the caller, after an OccurrenceScopePrompt choice) when the + // clicked event is an occurrence of a recurring series. + occurrenceStart?: string + scope?: 'this' | 'all' onClose: () => void onSaved: () => void } -export default function EventForm({ eventId, initialDate, onClose, onSaved }: EventFormProps) { +export default function EventForm({ eventId, initialDate, occurrenceStart, scope: scopeProp, onClose, onSaved }: EventFormProps) { const { user } = useAuth() const editing = eventId != null const permitted = editing ? can(user, 'edit') : can(user, 'create') + const scope: 'this' | 'all' = scopeProp ?? 'all' + // Editing a single occurrence — departments/assignees/attachments/repeat + // rules are series-level only and can't be touched from this path (see + // "deliberate scope limit" in the REST contract). + const editingOccurrence = editing && scope === 'this' const [loading, setLoading] = useState(editing) const [saving, setSaving] = useState(false) @@ -49,6 +59,8 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev const [selectedAssignees, setSelectedAssignees] = useState([]) const [attachments, setAttachments] = useState([]) const [isSystem, setIsSystem] = useState(false) + const [rrule, setRrule] = useState(null) + const [isRecurring, setIsRecurring] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [history, setHistory] = useState(null) @@ -70,7 +82,9 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev useEffect(() => { if (!editing || !eventId) return setLoading(true) - fetchEvent(eventId) + // For a 'this'-scoped edit, fetch the merged (master + override) fields + // for that one occurrence rather than the master row's own fields. + fetchEvent(eventId, editingOccurrence ? occurrenceStart : undefined) .then(ev => { setCalendarId(ev.calendar_id) setTitle(ev.title) @@ -87,9 +101,15 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev setSelectedAssignees(ev.assignees) setAttachments(ev.attachments) setIsSystem(ev.calendar.is_system) + setRrule(ev.rrule ?? null) + setIsRecurring(!!ev.is_recurring) }) .catch(err => setError(err instanceof Error ? err.message : 'Failed to load event')) .finally(() => setLoading(false)) + // editingOccurrence/occurrenceStart are fixed for the lifetime of a given + // EventForm mount (a fresh instance is mounted per open), so this only + // ever runs once per event. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventId, editing]) function toggleDept(dept: Department) { @@ -106,30 +126,41 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev async function handleSubmit(e: React.FormEvent) { e.preventDefault() - if (!calendarId) { setError('Choose a calendar'); return } + if (!editingOccurrence && !calendarId) { setError('Choose a calendar'); return } if (!title.trim()) { setError('Title is required'); return } const start = allDay ? new Date(`${startDate}T00:00:00`) : new Date(`${startDate}T${startTime}:00`) const end = allDay ? new Date(`${endDate}T23:59:00`) : new Date(`${endDate}T${endTime}:00`) if (end.getTime() < start.getTime()) { setError('End must be after start'); return } - const body: EventBody = { - calendar_id: calendarId, + const body: Partial = { title: title.trim(), description: description.trim() || null, location: location.trim() || null, start_at: start.toISOString(), end_at: end.toISOString(), all_day: allDay, - departments: selectedDepts, - assignees: selectedAssignees, + } + // A 'this'-scoped edit never touches calendar/departments/assignees/rrule + // — those are series-level, only changeable via a scope:'all' edit. + if (!editingOccurrence) { + body.calendar_id = calendarId! + body.departments = selectedDepts + body.assignees = selectedAssignees + body.rrule = rrule } setSaving(true) setError(null) try { - if (editing && eventId) await updateEvent(eventId, body) - else await createEvent(body) + if (editing && eventId) { + await updateEvent(eventId, body, { + occurrence_start: editingOccurrence ? occurrenceStart : undefined, + scope, + }) + } else { + await createEvent(body as EventBody) + } onSaved() onClose() } catch (err) { @@ -141,11 +172,19 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev async function handleDelete() { if (!eventId) return - if (!confirm('Delete this event? This cannot be undone.')) return + const msg = editingOccurrence + ? 'Delete this occurrence? Only this date is removed — the rest of the series is unaffected.' + : isRecurring + ? 'Delete this event and every occurrence in its series? This cannot be undone.' + : 'Delete this event? This cannot be undone.' + if (!confirm(msg)) return setSaving(true) setError(null) try { - await deleteEvent(eventId) + await deleteEvent(eventId, { + occurrence_start: editingOccurrence ? occurrenceStart : undefined, + scope, + }) onSaved() onClose() } catch (err) { @@ -192,6 +231,14 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev ) : (
+ {editingOccurrence && ( +
+ + Editing this occurrence only — calendar, departments, assignees and the repeat rule are + set for the whole series and can only be changed there. +
+ )} +
setTitle(e.target.value)} disabled={!permitted} required /> @@ -199,12 +246,16 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev
- + {editingOccurrence ? ( +
{calendars.find(c => c.id === calendarId)?.name ?? '—'}
+ ) : ( + + )}
@@ -247,47 +298,65 @@ export default function EventForm({ eventId, initialDate, onClose, onSaved }: Ev )}
+ {!editingOccurrence && ( + + )} +
-
- {departments.map(dept => ( - - ))} - {departments.length === 0 && No departments configured.} -
+ {editingOccurrence ? ( +
+ {selectedDepts.length === 0 && None} + {selectedDepts.map(d => {d.name})} +
+ ) : ( +
+ {departments.map(dept => ( + + ))} + {departments.length === 0 && No departments configured.} +
+ )}
-
- {users.map(u => ( - - ))} - {users.length === 0 && No staff available.} -
+ {editingOccurrence ? ( +
+ {selectedAssignees.length === 0 && None} + {selectedAssignees.map(a => {a.name})} +
+ ) : ( +
+ {users.map(u => ( + + ))} + {users.length === 0 && No staff available.} +
+ )}
{editing && eventId && ( )} diff --git a/frontend/src/components/OccurrenceScopePrompt.tsx b/frontend/src/components/OccurrenceScopePrompt.tsx new file mode 100644 index 0000000..21b576d --- /dev/null +++ b/frontend/src/components/OccurrenceScopePrompt.tsx @@ -0,0 +1,32 @@ +import { Repeat } from 'lucide-react' + +// Shown when the user tries to edit/delete an event that's an expanded +// occurrence of a recurring series (EventSummary.occurrence_start != null). +// Deliberately not a full modal (per spec) — just a small confirm-style +// overlay reusing .modal/.btn. There is no "this and future" option. +export default function OccurrenceScopePrompt({ title, onChoose, onCancel }: { + title: string + onChoose: (scope: 'this' | 'all') => void + onCancel: () => void +}) { + return ( +
+
e.stopPropagation()}> +
+ +

Recurring event

+
+

+ "{title}" is part of a recurring series. What would you like to change? +

+
+ + +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/RecurrenceEditor.tsx b/frontend/src/components/RecurrenceEditor.tsx new file mode 100644 index 0000000..575b604 --- /dev/null +++ b/frontend/src/components/RecurrenceEditor.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from 'react' +import { RRule } from 'rrule' +import { WEEKDAY_LABELS, toISODate } from '../dateUtils' + +// RRule.js weekday constants (MO..SU) — their .weekday numeric value is +// 0=Mon..6=Sun, which matches WEEKDAY_LABELS' Monday-first ordering. +const RRULE_WEEKDAYS = [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR, RRule.SA, RRule.SU] + +type Freq = 'DAILY' | 'WEEKLY' | 'MONTHLY' +type EndType = 'never' | 'count' | 'until' + +interface BuilderState { + enabled: boolean + freq: Freq + interval: number + byweekday: Set + endType: EndType + count: number + until: string // ISO date (yyyy-mm-dd) +} + +const FREQ_CODE: Record = { DAILY: RRule.DAILY, WEEKLY: RRule.WEEKLY, MONTHLY: RRule.MONTHLY } + +function parseInitial(rrule: string | null): BuilderState { + const fallback: BuilderState = { + enabled: false, freq: 'WEEKLY', interval: 1, byweekday: new Set(), endType: 'never', count: 10, until: '', + } + if (!rrule) return fallback + try { + const r = RRule.fromString(rrule) + const o = r.options + const freq: Freq = o.freq === RRule.DAILY ? 'DAILY' : o.freq === RRule.MONTHLY ? 'MONTHLY' : 'WEEKLY' + const byweekday = new Set(Array.isArray(o.byweekday) ? o.byweekday : []) + const endType: EndType = o.count != null ? 'count' : o.until ? 'until' : 'never' + return { + enabled: true, + freq, + interval: o.interval || 1, + byweekday, + endType, + count: o.count ?? 10, + until: o.until ? toISODate(new Date(o.until)) : '', + } + } catch { + return fallback + } +} + +// Builds the bare RRULE property value (no "RRULE:" prefix, no DTSTART line) +// that the backend expects — see EventForm.tsx for the contract note. +function buildRruleString(s: BuilderState): string | null { + if (!s.enabled) return null + const opts: ConstructorParameters[0] = { + freq: FREQ_CODE[s.freq], + interval: Math.max(1, s.interval || 1), + } + if (s.freq === 'WEEKLY' && s.byweekday.size > 0) { + opts.byweekday = [...s.byweekday].sort().map(i => RRULE_WEEKDAYS[i]) + } + if (s.endType === 'count') opts.count = Math.max(1, s.count || 1) + if (s.endType === 'until' && s.until) opts.until = new Date(`${s.until}T23:59:59Z`) + return new RRule(opts).toString().replace(/^RRULE:/, '') +} + +export default function RecurrenceEditor({ initialRrule, onChange, disabled }: { + initialRrule: string | null + onChange: (rrule: string | null) => void + disabled?: boolean +}) { + // Lazy-initialised once from the loaded event's rrule — this component is + // only ever mounted after the event's data has finished loading (or, for a + // new event, with initialRrule=null), so it never needs to re-sync later. + const [state, setState] = useState(() => parseInitial(initialRrule)) + + useEffect(() => { + onChange(buildRruleString(state)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.enabled, state.freq, state.interval, state.endType, state.count, state.until, state.byweekday]) + + function toggleDay(i: number) { + setState(s => { + const next = new Set(s.byweekday) + if (next.has(i)) next.delete(i) + else next.add(i) + return { ...s, byweekday: next } + }) + } + + return ( +
+ + + {state.enabled && ( +
+
+
+ + +
+
+ + setState(s => ({ ...s, interval: parseInt(e.target.value) || 1 }))} + disabled={disabled} + /> + + {state.freq === 'DAILY' ? 'day(s)' : state.freq === 'WEEKLY' ? 'week(s)' : 'month(s)'} + +
+
+ + {state.freq === 'WEEKLY' && ( +
+ +
+ {WEEKDAY_LABELS.map((lbl, i) => ( + !disabled && toggleDay(i)} + style={{ cursor: disabled ? 'default' : 'pointer' }} + > + {lbl} + + ))} +
+
+ )} + +
+ +
+ + + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/views/AgendaList.tsx b/frontend/src/components/views/AgendaList.tsx index 45ade0a..aa50c74 100644 --- a/frontend/src/components/views/AgendaList.tsx +++ b/frontend/src/components/views/AgendaList.tsx @@ -9,7 +9,7 @@ export default function AgendaList({ events, onSelectEvent }: { events: EventSummary[] date: Date onSelectDate?: (d: Date) => void - onSelectEvent: (id: number) => void + onSelectEvent: (ev: EventSummary) => void }) { const [q, setQ] = useState('') @@ -53,7 +53,7 @@ export default function AgendaList({ events, onSelectEvent }: {
{formatDayHeader(new Date(dayKey))}
{dayEvents.map(ev => ( -
onSelectEvent(ev.id)}> +
onSelectEvent(ev)}>
{ev.all_day ? 'All day' : formatTime(ev.start_at)}
diff --git a/frontend/src/components/views/DayGrid.tsx b/frontend/src/components/views/DayGrid.tsx index e3ee81d..631aa1d 100644 --- a/frontend/src/components/views/DayGrid.tsx +++ b/frontend/src/components/views/DayGrid.tsx @@ -5,7 +5,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: { events: EventSummary[] date: Date onSelectDate?: (d: Date) => void - onSelectEvent: (id: number) => void + onSelectEvent: (ev: EventSummary) => void }) { const allDayEvents = events.filter(ev => ev.all_day && eventOccursOnDay(ev, date)) const timedEvents = events.filter(ev => !ev.all_day && eventOccursOnDay(ev, date)) @@ -28,7 +28,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: { key={ev.id} className="cal-event-chip" style={{ background: ev.calendar_color, color: '#fff', marginBottom: 2 }} - onClick={() => onSelectEvent(ev.id)} + onClick={() => onSelectEvent(ev)} > {ev.title} @@ -53,7 +53,7 @@ export default function DayGrid({ events, date, onSelectDate, onSelectEvent }: { key={ev.id} className="cal-week-event" style={{ top, height, background: ev.calendar_color, color: '#fff' }} - onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }} + onClick={e => { e.stopPropagation(); onSelectEvent(ev) }} title={ev.title} > {formatTime(ev.start_at)} {ev.title} diff --git a/frontend/src/components/views/MonthGrid.tsx b/frontend/src/components/views/MonthGrid.tsx index 0106e45..d8a603b 100644 --- a/frontend/src/components/views/MonthGrid.tsx +++ b/frontend/src/components/views/MonthGrid.tsx @@ -7,7 +7,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }: events: EventSummary[] date: Date onSelectDate?: (d: Date) => void - onSelectEvent: (id: number) => void + onSelectEvent: (ev: EventSummary) => void }) { const days = monthGridDays(date) const today = new Date() @@ -35,7 +35,7 @@ export default function MonthGrid({ events, date, onSelectDate, onSelectEvent }: className="cal-event-chip" style={{ background: ev.calendar_color, color: '#fff' }} title={ev.title} - onClick={e => { e.stopPropagation(); onSelectEvent(ev.id) }} + onClick={e => { e.stopPropagation(); onSelectEvent(ev) }} > {!ev.all_day && `${formatTime(ev.start_at)} `}{ev.title} diff --git a/frontend/src/components/views/WeekGrid.tsx b/frontend/src/components/views/WeekGrid.tsx index 113a6d7..f9bf76e 100644 --- a/frontend/src/components/views/WeekGrid.tsx +++ b/frontend/src/components/views/WeekGrid.tsx @@ -8,7 +8,7 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }: events: EventSummary[] date: Date onSelectDate?: (d: Date) => void - onSelectEvent: (id: number) => void + onSelectEvent: (ev: EventSummary) => void }) { const weekStart = startOfWeek(date) const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) @@ -18,64 +18,66 @@ export default function WeekGrid({ events, date, onSelectDate, onSelectEvent }: const timedByDay = days.map(day => events.filter(ev => !ev.all_day && eventOccursOnDay(ev, day))) return ( -
-
- {days.map(day => ( -
onSelectDate?.(day)} - style={{ cursor: 'pointer' }} - > - {formatDayHeader(day)} -
- ))} +
+
+
+ {days.map(day => ( +
onSelectDate?.(day)} + style={{ cursor: 'pointer' }} + > + {formatDayHeader(day)} +
+ ))} -
- {days.map((day, i) => ( -
- {allDayByDay[i].map(ev => ( - onSelectEvent(ev.id)} - > - {ev.title} - +
+ {days.map((day, i) => ( +
+ {allDayByDay[i].map(ev => ( + onSelectEvent(ev)} + > + {ev.title} + + ))} +
+ ))} + +
+ {HOURS.map(h => ( +
{h === 0 ? '' : `${h}:00`}
))}
- ))} - -
- {HOURS.map(h => ( -
{h === 0 ? '' : `${h}:00`}
+ {days.map((day, i) => ( +
onSelectDate?.(day)} + > + {HOURS.map(h =>
)} + {timedByDay[i].map(ev => { + const { top, height } = timedLayout(ev, day) + return ( +
{ e.stopPropagation(); onSelectEvent(ev) }} + title={ev.title} + > + {formatTime(ev.start_at)} {ev.title} +
+ ) + })} +
))}
- {days.map((day, i) => ( -
onSelectDate?.(day)} - > - {HOURS.map(h =>
)} - {timedByDay[i].map(ev => { - const { top, height } = timedLayout(ev, day) - return ( -
{ e.stopPropagation(); onSelectEvent(ev.id) }} - title={ev.title} - > - {formatTime(ev.start_at)} {ev.title} -
- ) - })} -
- ))}
) } diff --git a/frontend/src/hooks/usePushSubscription.ts b/frontend/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..c3688d5 --- /dev/null +++ b/frontend/src/hooks/usePushSubscription.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import type { User } from '../types' + +const BASE = '/calendar/api' + +function urlBase64ToUint8Array(base64: string): ArrayBuffer { + const padding = '='.repeat((4 - (base64.length % 4)) % 4) + const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(b64) + const buf = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i) + return buf.buffer +} + +export function usePushSubscription(user: User) { + useEffect(() => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return + if (Notification.permission === 'denied') return + + async function subscribe() { + try { + const reg = await navigator.serviceWorker.ready + + const keyRes = await fetch(`${BASE}/push/vapid-key`, { credentials: 'include' }) + if (!keyRes.ok) return + const { publicKey } = await keyRes.json() as { publicKey: string } + + let sub = await reg.pushManager.getSubscription() + + if (!sub) { + if (Notification.permission === 'default') { + const permission = await Notification.requestPermission() + if (permission !== 'granted') return + } else if (Notification.permission !== 'granted') { + return + } + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }) + } + + // Always re-POST so subscription is tied to current user (handles re-login) + await fetch(`${BASE}/push/subscribe`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sub.toJSON()), + }) + } catch (err) { + console.error('[push] subscription failed:', err) + } + } + + subscribe() + }, [user.email]) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8f8237a..af7043e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -121,6 +121,21 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; } .top-bar { display: flex; } .app-shell { flex-direction: column; } .field-row { flex-direction: column; } + + /* Calendar page: stack the Calendars filter above the grid instead of + squeezing both into a shared row — flex-basis:0 on .cal-main means + flex-wrap alone never triggers here, so force it with flex-direction. */ + .cal-layout { flex-direction: column; } + .cal-sidebar-card { width: 100%; } + + /* Bigger tap targets for controls staff hit repeatedly on a phone */ + .btn-sm { padding: 8px 12px; font-size: 13px; } + .chip { padding: 8px 14px; } + .cal-event-chip { padding: 4px 8px; } + + /* Let the week grid keep usable column widths and scroll horizontally + instead of compressing 7 days into an unreadable strip */ + .cal-week-scroll .cal-week-grid:not(.cal-day-view) { min-width: 640px; } } .menu-backdrop { @@ -401,6 +416,14 @@ table.data tr.clickable:hover td { background: var(--body-bg); } Calendar-specific components ══════════════════════════════════════════════════════════════ */ +/* ── Calendar page layout ──────────────────────────────────────── */ +.cal-layout { display: flex; gap: 16px; align-items: flex-start; } +.cal-sidebar-card { width: 220px; flex-shrink: 0; } +.cal-main { flex: 1; min-width: 0; } + +.cal-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.cal-toolbar-label { font-weight: 600; font-size: 14px; flex: 1; min-width: 140px; } + /* ── View switcher (reuses .chip/.chip.active) ────────────────── */ .cal-view-switcher { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } @@ -488,6 +511,7 @@ table.data tr.clickable:hover td { background: var(--body-bg); } .cal-event-more { font-size: 11px; color: var(--text-mid); padding: 1px 6px; cursor: pointer; } /* ── Week / day grid ───────────────────────────────────────────── */ +.cal-week-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; } .cal-week-grid { display: grid; grid-template-columns: 56px repeat(7, 1fr); diff --git a/frontend/src/pages/CalDavSetup.tsx b/frontend/src/pages/CalDavSetup.tsx index f5e85ac..2fecd96 100644 --- a/frontend/src/pages/CalDavSetup.tsx +++ b/frontend/src/pages/CalDavSetup.tsx @@ -1,7 +1,18 @@ import { useEffect, useState, useCallback } from 'react' -import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react' +import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react' import type { CaldavCredential, CaldavCredentialCreated } from '../types' -import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api' +import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../api' + +function urlBase64ToUint8Array(base64: string): ArrayBuffer { + const padding = '='.repeat((4 - (base64.length % 4)) % 4) + const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(b64) + const buf = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i) + return buf.buffer +} + +type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking' export default function CalDavSetup() { const [credentials, setCredentials] = useState([]) @@ -11,6 +22,74 @@ export default function CalDavSetup() { const [creating, setCreating] = useState(false) const [revealed, setRevealed] = useState(null) + const [pushState, setPushState] = useState('checking') + const [pushBusy, setPushBusy] = useState(false) + const [pushError, setPushError] = useState(null) + + const refreshPushState = useCallback(async () => { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + setPushState('unsupported') + return + } + if (Notification.permission === 'denied') { + setPushState('denied') + return + } + try { + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + setPushState(sub ? 'on' : 'off') + } catch { + setPushState('off') + } + }, []) + + useEffect(() => { refreshPushState() }, [refreshPushState]) + + async function enablePush() { + setPushBusy(true) + setPushError(null) + try { + const reg = await navigator.serviceWorker.ready + if (Notification.permission === 'default') { + const permission = await Notification.requestPermission() + if (permission !== 'granted') { setPushState('denied'); return } + } else if (Notification.permission === 'denied') { + setPushState('denied') + return + } + const { publicKey } = await fetchVapidKey() + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }) + await subscribePush(sub.toJSON()) + setPushState('on') + } catch (err) { + setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications') + } finally { + setPushBusy(false) + } + } + + async function disablePush() { + setPushBusy(true) + setPushError(null) + try { + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + if (sub) { + await unsubscribePush(sub.endpoint) + await sub.unsubscribe() + } + setPushState('off') + } catch (err) { + setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications') + } finally { + setPushBusy(false) + } + } + const caldavUrl = `${window.location.origin}/calendar/caldav/` const reload = useCallback(() => { @@ -72,6 +151,34 @@ export default function CalDavSetup() { {error &&
{error}
} +
Push notifications
+
+ {pushState === 'on' ? ( + + ) : ( + + )} +
+
+ {pushState === 'unsupported' && 'Not supported on this device/browser'} + {pushState === 'denied' && 'Blocked — enable notifications for this site in your browser settings'} + {pushState === 'checking' && 'Checking status…'} + {pushState === 'off' && 'Notifications are off on this device'} + {pushState === 'on' && 'Notifications are on for this device'} +
+ {pushError &&
{pushError}
} +
+ {(pushState === 'off' || pushState === 'on') && ( + + )} +
+
Your CalDAV devices
{loading ? (
Loading…
diff --git a/frontend/src/pages/CalendarSettings.tsx b/frontend/src/pages/CalendarSettings.tsx index 063324f..ec9c1ee 100644 --- a/frontend/src/pages/CalendarSettings.tsx +++ b/frontend/src/pages/CalendarSettings.tsx @@ -1,7 +1,9 @@ import { useEffect, useState, useCallback } from 'react' import { Lock, Pencil, Plus, Trash2, X } from 'lucide-react' -import type { Calendar } from '../types' -import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar } from '../api' +import type { Calendar, AppConfig } from '../types' +import { can } from '../types' +import { useAuth } from '../components/AuthGate' +import { fetchCalendars, createCalendar, updateCalendar, deleteCalendar, fetchConfig, updateConfig } from '../api' // Curated swatch — similarly saturated hues that read well against the navy/gold theme. const SWATCHES = [ @@ -10,10 +12,17 @@ const SWATCHES = [ ] export default function CalendarSettings() { + const { user } = useAuth() + const isAdmin = can(user, 'admin') + const [calendars, setCalendars] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [config, setConfig] = useState(null) + const [configError, setConfigError] = useState(null) + const [configSavingKey, setConfigSavingKey] = useState(null) + const [editingId, setEditingId] = useState(null) const [editName, setEditName] = useState('') const [editColor, setEditColor] = useState(SWATCHES[0]) @@ -31,6 +40,27 @@ export default function CalendarSettings() { useEffect(() => { reload() }, [reload]) + useEffect(() => { + if (!isAdmin) return + fetchConfig().then(setConfig).catch(err => setConfigError(err instanceof Error ? err.message : 'Failed to load config')) + }, [isAdmin]) + + async function saveConfig(key: string, value: unknown) { + if (!config) return + setConfigSavingKey(key) + setConfigError(null) + const previous = config + setConfig({ ...config, [key]: value }) + try { + await updateConfig(key, value) + } catch (err) { + setConfig(previous) + setConfigError(err instanceof Error ? err.message : 'Failed to save setting') + } finally { + setConfigSavingKey(null) + } + } + function startEdit(cal: Calendar) { setEditingId(cal.id) setEditName(cal.name) @@ -195,6 +225,47 @@ export default function CalendarSettings() {
)} + + {isAdmin && ( + <> +
Notifications
+ {configError &&
{configError}
} + {!config ? ( +
Loading…
+ ) : ( +
+ + +
+ + saveConfig('reminder_minutes_before', parseInt(e.target.value) || 0)} + disabled={configSavingKey !== null || !config.notify_reminders} + /> +
+
+ )} + + )}
) } diff --git a/frontend/src/pages/CalendarView.tsx b/frontend/src/pages/CalendarView.tsx index fbf4f97..b6731a2 100644 --- a/frontend/src/pages/CalendarView.tsx +++ b/frontend/src/pages/CalendarView.tsx @@ -7,6 +7,7 @@ import { fetchCalendars, fetchEvents } from '../api' import CalendarToggleList from '../components/CalendarToggleList' import ViewSwitcher, { type CalendarViewKey } from '../components/ViewSwitcher' import EventForm from '../components/EventForm' +import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt' import MonthGrid from '../components/views/MonthGrid' import WeekGrid from '../components/views/WeekGrid' import DayGrid from '../components/views/DayGrid' @@ -55,7 +56,14 @@ export default function CalendarView() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [formState, setFormState] = useState<{ open: boolean; eventId?: number; initialDate?: Date }>({ open: false }) + const [formState, setFormState] = useState<{ + open: boolean + eventId?: number + initialDate?: Date + occurrenceStart?: string + scope?: 'this' | 'all' + }>({ open: false }) + const [scopePromptEvent, setScopePromptEvent] = useState(null) useEffect(() => { fetchCalendars().then(cals => { @@ -91,8 +99,26 @@ export default function CalendarView() { if (!can(user, 'create')) return setFormState({ open: true, initialDate: date }) } - function openEdit(id: number) { - setFormState({ open: true, eventId: id }) + // Recurring occurrences (occurrence_start != null) need the user to choose + // "this event" vs "the whole series" before we know what to fetch/save — + // a plain, non-recurring event skips straight to the form as before. + function openEdit(ev: EventSummary) { + if (ev.occurrence_start != null) { + setScopePromptEvent(ev) + } else { + setFormState({ open: true, eventId: ev.id }) + } + } + function chooseScope(scope: 'this' | 'all') { + const ev = scopePromptEvent + setScopePromptEvent(null) + if (!ev) return + setFormState({ + open: true, + eventId: ev.id, + occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined, + scope, + }) } function closeForm() { setFormState({ open: false }) @@ -119,14 +145,14 @@ export default function CalendarView() { {error &&
{error}
} -
-
+
+
Calendars
-
-
+
+
@@ -134,7 +160,7 @@ export default function CalendarView() { -
{labelFor(view, currentDate)}
+
{labelFor(view, currentDate)}
@@ -156,10 +182,20 @@ export default function CalendarView() { )} + + {scopePromptEvent && ( + setScopePromptEvent(null)} + /> + )}
) } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c4e0ab0..a01c5f2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -3,6 +3,7 @@ import { CalendarClock, Users2 } from 'lucide-react' import type { Department, EventSummary } from '../types' import { fetchMyUpcoming, fetchMyDepartments } from '../api' import EventForm from '../components/EventForm' +import OccurrenceScopePrompt from '../components/OccurrenceScopePrompt' import { formatDayHeader, formatTime } from '../dateUtils' export default function Dashboard() { @@ -10,7 +11,12 @@ export default function Dashboard() { const [departments, setDepartments] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [openEventId, setOpenEventId] = useState(null) + const [formState, setFormState] = useState<{ + eventId: number + occurrenceStart?: string + scope?: 'this' | 'all' + } | null>(null) + const [scopePromptEvent, setScopePromptEvent] = useState(null) const reload = useCallback(() => { setLoading(true) @@ -23,6 +29,24 @@ export default function Dashboard() { useEffect(() => { reload() }, [reload]) + function openEvent(ev: EventSummary) { + if (ev.occurrence_start != null) { + setScopePromptEvent(ev) + } else { + setFormState({ eventId: ev.id }) + } + } + function chooseScope(scope: 'this' | 'all') { + const ev = scopePromptEvent + setScopePromptEvent(null) + if (!ev) return + setFormState({ + eventId: ev.id, + occurrenceStart: scope === 'this' ? (ev.occurrence_start ?? undefined) : undefined, + scope, + }) + } + return (
@@ -63,7 +87,7 @@ export default function Dashboard() {
Nothing on your calendar in the next 7 days.
) : ( upcoming.map(ev => ( -
setOpenEventId(ev.id)}> +
openEvent(ev)}>
{ev.title}
@@ -77,13 +101,23 @@ export default function Dashboard() { )) )} - {openEventId !== null && ( + {formState && ( setOpenEventId(null)} + eventId={formState.eventId} + occurrenceStart={formState.occurrenceStart} + scope={formState.scope} + onClose={() => setFormState(null)} onSaved={reload} /> )} + + {scopePromptEvent && ( + setScopePromptEvent(null)} + /> + )}
) } diff --git a/frontend/src/sw.js b/frontend/src/sw.js index c5e58a3..6c0123d 100644 --- a/frontend/src/sw.js +++ b/frontend/src/sw.js @@ -9,3 +9,27 @@ registerRoute( denylist: [/\/api\//], }) ) + +self.addEventListener('push', event => { + const data = event.data?.json() ?? {} + event.waitUntil( + self.registration.showNotification(data.title || 'Calendar', { + body: data.body || '', + icon: '/calendar/icons/icon-192.png', + badge: '/calendar/icons/icon-192.png', + data: { url: data.url || '/calendar/' }, + tag: data.tag || 'calendar', + }) + ) +}) + +self.addEventListener('notificationclick', event => { + event.notification.close() + const url = event.notification.data?.url || '/calendar/' + event.waitUntil( + clients.matchAll({ type: 'window' }).then(list => { + const existing = list.find(c => c.url.includes('/calendar/') && 'focus' in c) + return existing ? existing.focus() : clients.openWindow(url) + }) + ) +}) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7c4cea7..c098312 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -22,6 +22,12 @@ export interface EventSummary { department_names: string[] assignee_names: string[] attachment_count: number + rrule: string | null + // Non-null only when this row is an expanded occurrence of a recurring + // series — use it as the stable key for "this specific occurrence" in + // edit/delete calls, even though the displayed start_at may differ from + // occurrence_start if a per-occurrence override moved it. + occurrence_start: string | null } export interface EventDepartment { @@ -53,6 +59,10 @@ export interface EventDetail extends EventSummary { color: string is_system: boolean } + // Only meaningful when the detail was fetched with ?occurrence_start= — + // otherwise absent/false. + is_recurring?: boolean + is_exception?: boolean } export interface ActivityLogEntry { @@ -107,3 +117,12 @@ export interface User { export function can(user: User, cap: string): boolean { return user.is_admin || user.caps.includes(cap) } + +// Flat app config — GET /api/config. Backend may carry additional keys +// beyond the ones the UI currently surfaces, hence the index signature. +export interface AppConfig { + notify_on_assignment: boolean + notify_reminders: boolean + reminder_minutes_before: number + [key: string]: unknown +}