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