feat(calendar): add reminder functionality for events with notifications
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
Some checks failed
Build & Push Docker Image / build (push) Has been cancelled
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
90
server/jobs/calendarReminders.js
Normal file
90
server/jobs/calendarReminders.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user