Add recurring events, push notifications, config sync — and mobile layout fixes

Recurring event editing (rrule) with this/all occurrence scope, web push
subscriptions (VAPID) with a cached config layer for notification settings,
and email delivery via nodemailer.

Also fixes the calendar view never actually stacking on mobile: the
Calendars filter column used flex:1 with minWidth:0 on its sibling, so
flex-wrap never triggered regardless of viewport width, squeezing the grid
and view switcher into a sliver next to a fixed 220px sidebar. Adds a
proper mobile breakpoint that stacks the layout, scrolls the week grid
horizontally instead of compressing it, and enlarges touch targets.
This commit is contained in:
jtricerolph 2026-07-25 23:04:59 +00:00
parent dad3cc472c
commit ca0dc9b070
28 changed files with 1517 additions and 155 deletions

View file

@ -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
}

89
backend/src/lib/mailer.js Normal file
View file

@ -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" <noreply@localhost>`,
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)}`
)
}

81
backend/src/lib/push.js Normal file
View file

@ -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))
}

View file

@ -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 }
}