feat(calendar): add reminder functionality for events with notifications
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled

This commit is contained in:
2026-03-04 10:18:43 +01:00
parent ce2cf499dc
commit 8823f8789e
9 changed files with 192 additions and 8 deletions

View File

@@ -513,6 +513,12 @@ export async function initDatabase() {
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'reminder_minutes'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'reminder_sent_at'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN reminder_sent_at DATETIME DEFAULT NULL');
}
// Calendar invitations (federated calendar events that must be accepted first)
if (isPostgres) {

View File

@@ -18,6 +18,7 @@ import caldavRoutes from './routes/caldav.js';
import notificationRoutes from './routes/notifications.js';
import oauthRoutes from './routes/oauth.js';
import { startFederationSync } from './jobs/federationSync.js';
import { startCalendarReminders } from './jobs/calendarReminders.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -93,6 +94,8 @@ async function start() {
// Start periodic federation sync job (checks remote room settings every 60s)
startFederationSync();
// Start calendar reminder job (sends in-app + browser notifications before events)
startCalendarReminders();
}
start().catch(err => {

View File

@@ -0,0 +1,90 @@
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
const CHECK_INTERVAL_MS = 60_000; // every minute
let timer = null;
/**
* Check for upcoming calendar events that need a reminder notification fired.
* Runs every minute. Updates `reminder_sent_at` after firing so reminders
* are never sent twice. Also resets `reminder_sent_at` to NULL when
* start_time or reminder_minutes is changed (handled in calendar route).
*/
async function runCheck() {
try {
const db = getDb();
// Fetch all events that have a reminder configured and haven't been sent yet
const pending = await db.all(`
SELECT ce.id, ce.uid, ce.title, ce.start_time, ce.reminder_minutes, ce.user_id,
ce.room_uid, ce.color
FROM calendar_events ce
WHERE ce.reminder_minutes IS NOT NULL
AND ce.reminder_sent_at IS NULL
`);
if (pending.length === 0) return;
const now = new Date();
const toFire = pending.filter(ev => {
const start = new Date(ev.start_time);
// Don't fire reminders for events that started more than 10 minutes ago (server downtime tolerance)
if (start < new Date(now.getTime() - 10 * 60_000)) return false;
const reminderTime = new Date(start.getTime() - ev.reminder_minutes * 60_000);
return reminderTime <= now;
});
for (const ev of toFire) {
try {
// Mark as sent immediately to prevent double-fire even if notification creation fails
await db.run(
'UPDATE calendar_events SET reminder_sent_at = ? WHERE id = ?',
[now.toISOString(), ev.id],
);
const start = new Date(ev.start_time);
const timeStr = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const dateStr = start.toLocaleDateString([], { day: 'numeric', month: 'short' });
const body = `${dateStr} · ${timeStr}`;
const link = '/calendar';
// Notify the owner
await createNotification(ev.user_id, 'calendar_reminder', ev.title, body, link);
// Notify all accepted share users as well
const shares = await db.all(
'SELECT user_id FROM calendar_event_shares WHERE event_id = ?',
[ev.id],
);
for (const { user_id } of shares) {
await createNotification(user_id, 'calendar_reminder', ev.title, body, link);
}
log.server.info(`Calendar reminder fired for event ${ev.uid} (id=${ev.id})`);
} catch (evErr) {
log.server.error(`Calendar reminder failed for event ${ev.id}: ${evErr.message}`);
}
}
} catch (err) {
log.server.error(`Calendar reminder job error: ${err.message}`);
}
}
export function startCalendarReminders() {
if (timer) return;
// Slight delay on startup so DB is fully ready
setTimeout(() => {
runCheck();
timer = setInterval(runCheck, CHECK_INTERVAL_MS);
}, 5_000);
log.server.info('Calendar reminder job started');
}
export function stopCalendarReminders() {
if (timer) {
clearInterval(timer);
timer = null;
}
}

View File

@@ -144,6 +144,15 @@ function eventToICS(event, base, user) {
if (joinUrl) {
lines.push(`X-REDLIGHT-JOIN-URL:${escapeICS(joinUrl)}`);
}
if (event.reminder_minutes) {
lines.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
lines.push('END:VEVENT', 'END:VCALENDAR');
return lines.map(foldICSLine).join('\r\n');
}

View File

@@ -19,6 +19,9 @@ const router = Router();
// Allowlist for CSS color values - only permits hsl(), hex (#rgb/#rrggbb) and plain names
const SAFE_COLOR_RE = /^(?:#[0-9a-fA-F]{3,8}|hsl\(\d{1,3},\s*\d{1,3}%,\s*\d{1,3}%\)|[a-zA-Z]{1,30})$/;
// Allowed reminder intervals in minutes
const VALID_REMINDERS = new Set([5, 15, 30, 60, 120, 1440]);
// Rate limit for federation calendar receive
const calendarFederationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
@@ -117,7 +120,7 @@ router.get('/events/:id', authenticateToken, async (req, res) => {
// ── POST /api/calendar/events — Create event ────────────────────────────────
router.post('/events', authenticateToken, async (req, res) => {
try {
const { title, description, start_time, end_time, room_uid, color } = req.body;
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
@@ -146,9 +149,12 @@ router.post('/events', authenticateToken, async (req, res) => {
}
const uid = crypto.randomBytes(12).toString('hex');
const validReminder = (reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes)))
? Number(reminder_minutes) : null;
const result = await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, reminder_minutes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
title.trim(),
@@ -158,6 +164,7 @@ router.post('/events', authenticateToken, async (req, res) => {
room_uid || null,
req.user.id,
color || '#6366f1',
validReminder,
]);
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
@@ -175,7 +182,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const { title, description, start_time, end_time, room_uid, color } = req.body;
const { title, description, start_time, end_time, room_uid, color, reminder_minutes } = req.body;
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
@@ -197,6 +204,13 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
const validReminder = (reminder_minutes !== undefined)
? ((reminder_minutes != null && VALID_REMINDERS.has(Number(reminder_minutes))) ? Number(reminder_minutes) : null)
: undefined;
// Reset reminder_sent_at when start_time or reminder_minutes changes so the job re-fires
const resetReminder = (start_time !== undefined && start_time !== event.start_time)
|| (reminder_minutes !== undefined && validReminder !== event.reminder_minutes);
await db.run(`
UPDATE calendar_events SET
title = COALESCE(?, title),
@@ -205,6 +219,8 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time = COALESCE(?, end_time),
room_uid = ?,
color = COALESCE(?, color),
reminder_minutes = COALESCE(?, reminder_minutes),
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
@@ -214,6 +230,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null,
validReminder !== undefined ? validReminder : null,
req.params.id,
]);
@@ -686,6 +703,15 @@ function generateICS(event, location, prodIdDomain) {
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
}
if (event.reminder_minutes) {
ics.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`DESCRIPTION:Reminder: ${escapeICS(event.title)}`,
`TRIGGER:-PT${event.reminder_minutes}M`,
'END:VALARM',
);
}
ics.push('END:VEVENT', 'END:VCALENDAR');
return ics.join('\r\n');
}