diff --git a/server/config/database.js b/server/config/database.js index e057b9a..4d8ca55 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -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) { diff --git a/server/index.js b/server/index.js index f3bdd8e..421305e 100644 --- a/server/index.js +++ b/server/index.js @@ -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 => { diff --git a/server/jobs/calendarReminders.js b/server/jobs/calendarReminders.js new file mode 100644 index 0000000..d416983 --- /dev/null +++ b/server/jobs/calendarReminders.js @@ -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; + } +} diff --git a/server/routes/caldav.js b/server/routes/caldav.js index cfe7fee..9e1f457 100644 --- a/server/routes/caldav.js +++ b/server/routes/caldav.js @@ -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'); } diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 051cb3d..89c54f9 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -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'); } diff --git a/src/contexts/NotificationContext.jsx b/src/contexts/NotificationContext.jsx index 01b07a1..ba330e5 100644 --- a/src/contexts/NotificationContext.jsx +++ b/src/contexts/NotificationContext.jsx @@ -77,6 +77,15 @@ export function NotificationProvider({ children }) { seenIds.current.add(n.id); const icon = notificationIcon(n.type); toast(`${icon} ${n.title}`, { duration: 5000 }); + // Browser notification for calendar reminders + if (n.type === 'calendar_reminder' && 'Notification' in window) { + const fire = () => new Notification(n.title, { body: n.body || '', icon: '/favicon.ico' }); + if (Notification.permission === 'granted') { + fire(); + } else if (Notification.permission !== 'denied') { + Notification.requestPermission().then(p => { if (p === 'granted') fire(); }); + } + } }); } catch { /* silent – server may not be reachable */ @@ -166,6 +175,7 @@ function notificationIcon(type) { case 'room_share_added': return '🔗'; case 'room_share_removed': return '🚫'; case 'federation_invite_received': return '📩'; + case 'calendar_reminder': return '🔔'; default: return '🔔'; } } diff --git a/src/i18n/de.json b/src/i18n/de.json index 486497b..46ba05f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -510,6 +510,14 @@ "linkedRoom": "Verknüpfter Raum", "noRoom": "Kein Raum (kein Videomeeting)", "linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.", + "reminderLabel": "Erinnerung", + "reminderNone": "Keine Erinnerung", + "reminder5": "5 Minuten vorher", + "reminder15": "15 Minuten vorher", + "reminder30": "30 Minuten vorher", + "reminder60": "1 Stunde vorher", + "reminder120": "2 Stunden vorher", + "reminder1440": "1 Tag vorher", "timezone": "Zeitzone", "color": "Farbe", "eventCreated": "Event erstellt!", diff --git a/src/i18n/en.json b/src/i18n/en.json index a1f8f06..a8a5194 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -510,6 +510,14 @@ "linkedRoom": "Linked Room", "noRoom": "No room (no video meeting)", "linkedRoomHint": "Link a room to automatically include the join-URL in the event.", + "reminderLabel": "Reminder", + "reminderNone": "No reminder", + "reminder5": "5 minutes before", + "reminder15": "15 minutes before", + "reminder30": "30 minutes before", + "reminder60": "1 hour before", + "reminder120": "2 hours before", + "reminder1440": "1 day before", "timezone": "Timezone", "color": "Color", "eventCreated": "Event created!", diff --git a/src/pages/Calendar.jsx b/src/pages/Calendar.jsx index df4aed5..3fc4458 100644 --- a/src/pages/Calendar.jsx +++ b/src/pages/Calendar.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { - ChevronLeft, ChevronRight, Plus, Clock, Video, + ChevronLeft, ChevronRight, Plus, Clock, Video, Bell, Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink, } from 'lucide-react'; import api from '../services/api'; @@ -32,7 +32,7 @@ export default function Calendar() { // Create/Edit form const [form, setForm] = useState({ title: '', description: '', start_time: '', end_time: '', - room_uid: '', color: '#6366f1', + room_uid: '', color: '#6366f1', reminder_minutes: null, }); const [saving, setSaving] = useState(false); @@ -157,7 +157,7 @@ export default function Calendar() { title: '', description: '', start_time: toLocalDateTimeStr(start), end_time: toLocalDateTimeStr(end), - room_uid: '', color: '#6366f1', + room_uid: '', color: '#6366f1', reminder_minutes: null, }); setEditingEvent(null); setShowCreate(true); @@ -171,6 +171,7 @@ export default function Calendar() { end_time: toLocalDateTimeStr(new Date(ev.end_time)), room_uid: ev.room_uid || '', color: ev.color || '#6366f1', + reminder_minutes: ev.reminder_minutes ?? null, }); setEditingEvent(ev); setShowDetail(null); @@ -494,7 +495,10 @@ export default function Calendar() { className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity" style={{ backgroundColor: ev.color || '#6366f1' }} > -
{ev.title}
+
+ {ev.reminder_minutes && } + {ev.title} +
{formatTime(ev.start_time)} - {formatTime(ev.end_time)}
))} @@ -570,6 +574,26 @@ export default function Calendar() {

{t('calendar.linkedRoomHint')}

+
+ +
+ + +
+
+