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

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