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:
parent
dad3cc472c
commit
ca0dc9b070
28 changed files with 1517 additions and 155 deletions
17
backend/src/lib/config-cache.js
Normal file
17
backend/src/lib/config-cache.js
Normal 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
89
backend/src/lib/mailer.js
Normal 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
81
backend/src/lib/push.js
Normal 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))
|
||||
}
|
||||
122
backend/src/lib/recurrence.js
Normal file
122
backend/src/lib/recurrence.js
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue