diff --git a/package-lock.json b/package-lock.json index 258d3c2..91fd8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "redlight", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "redlight", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "axios": "^1.7.0", "bcryptjs": "^2.4.3", diff --git a/package.json b/package.json index 57fc468..f6e953a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "redlight", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"", diff --git a/server/config/database.js b/server/config/database.js index f7d1430..39582ab 100644 --- a/server/config/database.js +++ b/server/config/database.js @@ -558,6 +558,65 @@ export async function initDatabase() { `); } + // Track outbound calendar event federation sends for deletion propagation + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_event_outbound ( + id SERIAL PRIMARY KEY, + event_uid TEXT NOT NULL, + remote_domain TEXT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(event_uid, remote_domain) + ); + CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_event_outbound ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_uid TEXT NOT NULL, + remote_domain TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(event_uid, remote_domain) + ); + CREATE INDEX IF NOT EXISTS idx_cal_out_event_uid ON calendar_event_outbound(event_uid); + `); + } + + // Local calendar event invitations (share-with-acceptance flow) + if (isPostgres) { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_local_invitations ( + id SERIAL PRIMARY KEY, + event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(event_id, to_user_id) + ); + CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id); + CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id); + `); + } else { + await db.exec(` + CREATE TABLE IF NOT EXISTS calendar_local_invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id INTEGER NOT NULL, + from_user_id INTEGER NOT NULL, + to_user_id INTEGER NOT NULL, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(event_id, to_user_id), + FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE, + FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_cal_local_inv_to_user ON calendar_local_invitations(to_user_id); + CREATE INDEX IF NOT EXISTS idx_cal_local_inv_event ON calendar_local_invitations(event_id); + `); + } + // ── Default admin (only on very first start) ──────────────────────────── const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'"); if (!adminAlreadySeeded) { diff --git a/server/config/mailer.js b/server/config/mailer.js index edc114f..b12e7ed 100644 --- a/server/config/mailer.js +++ b/server/config/mailer.js @@ -116,7 +116,7 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me await transporter.sendMail({ from: `"${headerAppName}" <${from}>`, to, - subject: `${headerAppName} – Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, + subject: `${headerAppName} - Meeting invitation from ${sanitizeHeaderValue(fromUser)}`, html: `

Hey ${safeName} 👋

@@ -140,6 +140,83 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me }); } +/** + * Send a calendar event invitation email (federated). + */ +export async function sendCalendarInviteEmail(to, name, fromUser, title, startTime, endTime, description, inboxUrl, appName = 'Redlight') { + if (!transporter) return; + + const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const headerAppName = sanitizeHeaderValue(appName); + const safeName = escapeHtml(name); + const safeFromUser = escapeHtml(fromUser); + const safeTitle = escapeHtml(title); + const safeDesc = description ? escapeHtml(description) : null; + + const formatDate = (iso) => { + try { return new Date(iso).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' }); } + catch { return iso; } + }; + + await transporter.sendMail({ + from: `"${headerAppName}" <${from}>`, + to, + subject: `${headerAppName} - Calendar invitation from ${sanitizeHeaderValue(fromUser)}`, + html: ` +
+

Hey ${safeName} 👋

+

You have received a calendar invitation from ${safeFromUser}.

+
+

${safeTitle}

+

🕐 ${escapeHtml(formatDate(startTime))} – ${escapeHtml(formatDate(endTime))}

+ ${safeDesc ? `

"${safeDesc}"

` : ''} +
+

+ + View Invitation + +

+
+

Open the link above to accept or decline the invitation.

+
+ `, + text: `Hey ${name},\n\nYou have received a calendar invitation from ${fromUser}.\nEvent: ${title}\nTime: ${formatDate(startTime)} \u2013 ${formatDate(endTime)}${description ? `\n\n"${description}"` : ''}\n\nView invitation: ${inboxUrl}\n\n\u2013 ${appName}`, + }); +} + +/** + * Notify a user that a federated calendar event they received was deleted by the organiser. + */ +export async function sendCalendarEventDeletedEmail(to, name, fromUser, title, appName = 'Redlight') { + if (!transporter) return; + + const from = process.env.SMTP_FROM || process.env.SMTP_USER; + const headerAppName = sanitizeHeaderValue(appName); + const safeName = escapeHtml(name); + const safeFromUser = escapeHtml(fromUser); + const safeTitle = escapeHtml(title); + + await transporter.sendMail({ + from: `"${headerAppName}" <${from}>`, + to, + subject: `${headerAppName} – Calendar event cancelled: ${sanitizeHeaderValue(title)}`, + html: ` +
+

Hey ${safeName} 👋

+

The following calendar event was deleted by the organiser (${safeFromUser}) and is no longer available:

+
+

${safeTitle}

+
+

The event has been automatically removed from your calendar.

+
+

This message was sent automatically by ${escapeHtml(appName)}.

+
+ `, + text: `Hey ${name},\n\nThe calendar event "${title}" by ${fromUser} has been deleted and removed from your calendar.\n\n\u2013 ${appName}`, + }); +} + /** * Send a user registration invite email. * @param {string} to – recipient email diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 2f47ea7..b3a198c 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -3,6 +3,7 @@ import crypto from 'crypto'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; import { log } from '../config/logger.js'; +import { sendCalendarInviteEmail } from '../config/mailer.js'; import { isFederationEnabled, getFederationDomain, @@ -92,7 +93,18 @@ router.get('/events/:id', authenticateToken, async (req, res) => { `, [event.id]); event.is_owner = event.user_id === req.user.id; - res.json({ event, sharedUsers }); + + let pendingInvitations = []; + if (event.user_id === req.user.id) { + pendingInvitations = await db.all(` + SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image + FROM calendar_local_invitations cli + JOIN users u ON cli.to_user_id = u.id + WHERE cli.event_id = ? AND cli.status = 'pending' + `, [event.id]); + } + + res.json({ event, sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar get event error: ${err.message}`); res.status(500).json({ error: 'Event could not be loaded' }); @@ -207,6 +219,42 @@ router.delete('/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' }); + // Propagate deletion to all remote instances that received this event + if (isFederationEnabled()) { + try { + const outbound = await db.all( + 'SELECT remote_domain FROM calendar_event_outbound WHERE event_uid = ?', + [event.uid] + ); + for (const { remote_domain } of outbound) { + try { + const payload = { + event_uid: event.uid, + from_user: `@${req.user.name}@${getFederationDomain()}`, + timestamp: new Date().toISOString(), + }; + const signature = signPayload(payload); + const { baseUrl: remoteApi } = await discoverInstance(remote_domain); + await fetch(`${remoteApi}/calendar-event-deleted`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Federation-Signature': signature, + 'X-Federation-Origin': getFederationDomain(), + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), + }); + } catch (remoteErr) { + log.server.warn(`Calendar deletion propagation failed for ${remote_domain}: ${remoteErr.message}`); + } + } + await db.run('DELETE FROM calendar_event_outbound WHERE event_uid = ?', [event.uid]); + } catch (propErr) { + log.server.warn(`Calendar deletion propagation error: ${propErr.message}`); + } + } + await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]); res.json({ message: 'Event deleted' }); } catch (err) { @@ -215,7 +263,7 @@ router.delete('/events/:id', authenticateToken, async (req, res) => { } }); -// ── POST /api/calendar/events/:id/share — Share event with local user ─────── +// ── POST /api/calendar/events/:id/share — Invite local user to event ──────── router.post('/events/:id/share', authenticateToken, async (req, res) => { try { const { user_id } = req.body; @@ -229,7 +277,35 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]); if (existing) return res.status(400).json({ error: 'Already shared with this user' }); - await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [event.id, user_id]); + const pendingCheck = await db.get( + "SELECT id FROM calendar_local_invitations WHERE event_id = ? AND to_user_id = ? AND status = 'pending'", + [event.id, user_id] + ); + if (pendingCheck) return res.status(400).json({ error: 'Invitation already pending for this user' }); + + await db.run( + 'INSERT INTO calendar_local_invitations (event_id, from_user_id, to_user_id) VALUES (?, ?, ?)', + [event.id, req.user.id, user_id] + ); + + // Send notification email (fire-and-forget) + const targetUser = await db.get('SELECT name, display_name, email FROM users WHERE id = ?', [user_id]); + if (targetUser?.email) { + const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const inboxUrl = `${appUrl}/federation/inbox`; + const appName = process.env.APP_NAME || 'Redlight'; + const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]); + const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name); + sendCalendarInviteEmail( + targetUser.email, + (targetUser.display_name && targetUser.display_name !== '') ? targetUser.display_name : targetUser.name, + fromDisplay, + event.title, event.start_time, event.end_time, event.description, + inboxUrl, appName + ).catch(mailErr => { + log.server.warn('Calendar local share mail failed (non-fatal):', mailErr.message); + }); + } const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image @@ -238,22 +314,36 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => { WHERE ces.event_id = ? `, [event.id]); - res.json({ sharedUsers }); + const pendingInvitations = await db.all(` + SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image + FROM calendar_local_invitations cli + JOIN users u ON cli.to_user_id = u.id + WHERE cli.event_id = ? AND cli.status = 'pending' + `, [event.id]); + + res.json({ sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar share error: ${err.message}`); res.status(500).json({ error: 'Could not share event' }); } }); -// ── DELETE /api/calendar/events/:id/share/:userId — Remove share ──────────── +// ── DELETE /api/calendar/events/:id/share/:userId — Remove share or cancel invitation ── router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => { try { const db = getDb(); 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' }); + // Remove accepted share await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]); + // Also cancel any pending local invitation for this user + await db.run( + "UPDATE calendar_local_invitations SET status = 'declined' WHERE event_id = ? AND to_user_id = ? AND status = 'pending'", + [event.id, parseInt(req.params.userId)] + ); + const sharedUsers = await db.all(` SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image FROM calendar_event_shares ces @@ -261,13 +351,91 @@ router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) = WHERE ces.event_id = ? `, [event.id]); - res.json({ sharedUsers }); + const pendingInvitations = await db.all(` + SELECT cli.id, cli.to_user_id as user_id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image + FROM calendar_local_invitations cli + JOIN users u ON cli.to_user_id = u.id + WHERE cli.event_id = ? AND cli.status = 'pending' + `, [event.id]); + + res.json({ sharedUsers, pendingInvitations }); } catch (err) { log.server.error(`Calendar unshare error: ${err.message}`); res.status(500).json({ error: 'Could not remove share' }); } }); +// ── GET /api/calendar/local-invitations — List local calendar invitations for current user ── +router.get('/local-invitations', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const invitations = await db.all(` + SELECT + cli.id, cli.event_id, cli.status, cli.created_at, + ce.title, ce.start_time, ce.end_time, ce.description, ce.color, + COALESCE(NULLIF(u.display_name,''), u.name) as from_name + FROM calendar_local_invitations cli + JOIN calendar_events ce ON cli.event_id = ce.id + JOIN users u ON cli.from_user_id = u.id + WHERE cli.to_user_id = ? + ORDER BY cli.created_at DESC + `, [req.user.id]); + res.json({ invitations }); + } catch (err) { + log.server.error(`Calendar local invitations error: ${err.message}`); + res.status(500).json({ error: 'Could not load invitations' }); + } +}); + +// ── POST /api/calendar/local-invitations/:id/accept — Accept local invitation ── +router.post('/local-invitations/:id/accept', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const inv = await db.get( + "SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ? AND status = 'pending'", + [req.params.id, req.user.id] + ); + if (!inv) return res.status(404).json({ error: 'Invitation not found' }); + + await db.run("UPDATE calendar_local_invitations SET status = 'accepted' WHERE id = ?", [inv.id]); + // Insert into calendar_event_shares so the event appears in the user's calendar + const existingShare = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]); + if (!existingShare) { + await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [inv.event_id, req.user.id]); + } + res.json({ message: 'Invitation accepted' }); + } catch (err) { + log.server.error(`Calendar local invitation accept error: ${err.message}`); + res.status(500).json({ error: 'Could not accept invitation' }); + } +}); + +// ── DELETE /api/calendar/local-invitations/:id — Decline/remove local invitation ── +router.delete('/local-invitations/:id', authenticateToken, async (req, res) => { + try { + const db = getDb(); + const inv = await db.get( + 'SELECT * FROM calendar_local_invitations WHERE id = ? AND to_user_id = ?', + [req.params.id, req.user.id] + ); + if (!inv) return res.status(404).json({ error: 'Invitation not found' }); + + if (inv.status === 'pending') { + await db.run("UPDATE calendar_local_invitations SET status = 'declined' WHERE id = ?", [inv.id]); + } else { + // Accepted/declined – remove the share too if it was accepted + if (inv.status === 'accepted') { + await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [inv.event_id, req.user.id]); + } + await db.run('DELETE FROM calendar_local_invitations WHERE id = ?', [inv.id]); + } + res.json({ message: 'Invitation removed' }); + } catch (err) { + log.server.error(`Calendar local invitation delete error: ${err.message}`); + res.status(500).json({ error: 'Could not remove invitation' }); + } +}); + // ── GET /api/calendar/events/:id/ics — Download event as ICS ──────────────── router.get('/events/:id/ics', authenticateToken, async (req, res) => { try { @@ -364,6 +532,15 @@ router.post('/events/:id/federation', authenticateToken, async (req, res) => { throw new Error(data.error || `Remote server responded with ${response.status}`); } + // Track outbound send for deletion propagation + try { + await db.run( + `INSERT INTO calendar_event_outbound (event_uid, remote_domain) VALUES (?, ?) + ON CONFLICT(event_uid, remote_domain) DO NOTHING`, + [event.uid, domain] + ); + } catch { /* table may not exist yet on upgrade */ } + res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation send error: ${err.message}`); @@ -434,6 +611,20 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as '#6366f1', ]); + // Send notification email (fire-and-forget) + if (targetUser.email) { + const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; + const inboxUrl = `${appUrl}/federation/inbox`; + const appName = process.env.APP_NAME || 'Redlight'; + sendCalendarInviteEmail( + targetUser.email, targetUser.name, from_user, + title, start_time, end_time, description || null, + inboxUrl, appName + ).catch(mailErr => { + log.server.warn('Calendar invite mail failed (non-fatal):', mailErr.message); + }); + } + res.json({ success: true }); } catch (err) { log.server.error(`Calendar federation receive error: ${err.message}`); diff --git a/server/routes/federation.js b/server/routes/federation.js index c26b067..74ce4fd 100644 --- a/server/routes/federation.js +++ b/server/routes/federation.js @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import { rateLimit } from 'express-rate-limit'; import { getDb } from '../config/database.js'; import { authenticateToken } from '../middleware/auth.js'; -import { sendFederationInviteEmail } from '../config/mailer.js'; +import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js'; import { log } from '../config/logger.js'; // M13: rate limit the unauthenticated federation receive endpoint @@ -39,7 +39,7 @@ export function wellKnownHandler(req, res) { federation_api: '/api/federation', public_key: getPublicKey(), software: 'Redlight', - version: '1.3.0', + version: '1.4.0', }); } @@ -274,7 +274,15 @@ router.get('/invitations/pending-count', authenticateToken, async (req, res) => [req.user.id] ); } catch { /* table may not exist yet */ } - res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) }); + let localCalResult = { count: 0 }; + try { + localCalResult = await db.get( + `SELECT COUNT(*) as count FROM calendar_local_invitations + WHERE to_user_id = ? AND status = 'pending'`, + [req.user.id] + ); + } catch { /* table may not exist yet */ } + res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) + (localCalResult?.count || 0) }); } catch (err) { res.json({ count: 0 }); } @@ -519,6 +527,96 @@ router.post('/room-sync', federationReceiveLimiter, async (req, res) => { } }); +// ── POST /api/federation/calendar-event-deleted — Receive calendar deletion ─ +router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res) => { + try { + if (!isFederationEnabled()) { + return res.status(400).json({ error: 'Federation is not configured on this instance' }); + } + + const signature = req.headers['x-federation-signature']; + const originDomain = req.headers['x-federation-origin']; + const payload = req.body || {}; + + if (!signature || !originDomain) { + return res.status(401).json({ error: 'Missing federation signature or origin' }); + } + + const { publicKey } = await discoverInstance(originDomain); + if (!publicKey || !verifyPayload(payload, signature, publicKey)) { + return res.status(403).json({ error: 'Invalid federation signature' }); + } + + const { event_uid } = payload; + if (!event_uid || typeof event_uid !== 'string') { + return res.status(400).json({ error: 'event_uid is required' }); + } + + const db = getDb(); + + // Collect all affected users before deleting (for email notifications) + let affectedUsers = []; + try { + // Users with pending/declined invitations + const invUsers = await db.all( + `SELECT u.email, u.name, ci.title, ci.from_user + FROM calendar_invitations ci + JOIN users u ON ci.to_user_id = u.id + WHERE ci.event_uid = ? AND ci.from_user LIKE ?`, + [event_uid, `%@${originDomain}`] + ); + // Users who already accepted (event in their calendar) + const calUsers = await db.all( + `SELECT u.email, u.name, ce.title, ce.federated_from AS from_user + FROM calendar_events ce + JOIN users u ON ce.user_id = u.id + WHERE ce.uid = ? AND ce.federated_from LIKE ?`, + [event_uid, `%@${originDomain}`] + ); + // Merge, deduplicate by email + const seen = new Set(); + for (const row of [...invUsers, ...calUsers]) { + if (row.email && !seen.has(row.email)) { + seen.add(row.email); + affectedUsers.push(row); + } + } + } catch { /* non-fatal */ } + + // Remove from calendar_invitations for all users on this instance + await db.run( + `DELETE FROM calendar_invitations + WHERE event_uid = ? AND from_user LIKE ?`, + [event_uid, `%@${originDomain}`] + ); + + // Remove from calendar_events (accepted invitations) for all users on this instance + await db.run( + `DELETE FROM calendar_events + WHERE uid = ? AND federated_from LIKE ?`, + [event_uid, `%@${originDomain}`] + ); + + log.federation.info(`Calendar event ${event_uid} deleted (origin: ${originDomain})`); + + // Notify affected users by email (fire-and-forget) + if (affectedUsers.length > 0) { + const appName = process.env.APP_NAME || 'Redlight'; + for (const u of affectedUsers) { + sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName) + .catch(mailErr => { + log.federation.warn(`Calendar deletion mail to ${u.email} failed (non-fatal): ${mailErr.message}`); + }); + } + } + + res.json({ success: true }); + } catch (err) { + log.federation.error('Calendar-event-deleted error:', err); + res.status(500).json({ error: 'Failed to process calendar event deletion' }); + } +}); + // ── POST /api/federation/room-deleted — Receive deletion notification ─────── // Origin instance pushes this to notify that a room has been deleted. router.post('/room-deleted', federationReceiveLimiter, async (req, res) => { diff --git a/src/i18n/de.json b/src/i18n/de.json index b4c2871..333398d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -411,6 +411,8 @@ "roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.", "calendarEvent": "Kalendereinladung", "calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!", + "localCalendarEvent": "Lokale Kalendereinladung", + "calendarLocalAccepted": "Einladung angenommen – Event wurde in deinen Kalender eingetragen!", "invitationRemoved": "Einladung entfernt", "removeInvitation": "Einladung entfernen" }, @@ -458,6 +460,11 @@ "shareAdded": "Benutzer zum Event hinzugefügt", "shareRemoved": "Freigabe entfernt", "shareFailed": "Event konnte nicht geteilt werden", + "invitationSent": "Einladung gesendet!", + "invitationCancelled": "Einladung widerrufen", + "invitationPending": "Einladung ausstehend", + "pendingInvitations": "Ausstehende Einladungen", + "accepted": "Angenommen", "sendFederated": "An Remote senden", "sendFederatedTitle": "Event an Remote-Instanz senden", "sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.", diff --git a/src/i18n/en.json b/src/i18n/en.json index 21017d1..c67bba1 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -411,6 +411,8 @@ "roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.", "calendarEvent": "Calendar Invitation", "calendarAccepted": "Calendar event accepted and added to your calendar!", + "localCalendarEvent": "Local Calendar Invitation", + "calendarLocalAccepted": "Invitation accepted – event added to your calendar!", "invitationRemoved": "Invitation removed", "removeInvitation": "Remove invitation" }, @@ -458,6 +460,11 @@ "shareAdded": "User added to event", "shareRemoved": "Share removed", "shareFailed": "Could not share event", + "invitationSent": "Invitation sent!", + "invitationCancelled": "Invitation cancelled", + "invitationPending": "Invitation pending", + "pendingInvitations": "Pending Invitations", + "accepted": "Accepted", "sendFederated": "Send to remote", "sendFederatedTitle": "Send Event to Remote Instance", "sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.", diff --git a/src/pages/Calendar.jsx b/src/pages/Calendar.jsx index 704422a..57d547c 100644 --- a/src/pages/Calendar.jsx +++ b/src/pages/Calendar.jsx @@ -39,6 +39,7 @@ export default function Calendar() { const [shareSearch, setShareSearch] = useState(''); const [shareResults, setShareResults] = useState([]); const [sharedUsers, setSharedUsers] = useState([]); + const [pendingInvitations, setPendingInvitations] = useState([]); const [fedAddress, setFedAddress] = useState(''); const [fedSending, setFedSending] = useState(false); @@ -269,9 +270,11 @@ export default function Calendar() { setShowShare(ev); setShareSearch(''); setShareResults([]); + setPendingInvitations([]); try { const res = await api.get(`/calendar/events/${ev.id}`); setSharedUsers(res.data.sharedUsers || []); + setPendingInvitations(res.data.pendingInvitations || []); } catch { /* ignore */ } }; @@ -281,7 +284,8 @@ export default function Calendar() { try { const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`); const sharedIds = new Set(sharedUsers.map(u => u.id)); - setShareResults(res.data.users.filter(u => !sharedIds.has(u.id))); + const pendingIds = new Set(pendingInvitations.map(u => u.user_id)); + setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.has(u.id))); } catch { setShareResults([]); } }; @@ -290,9 +294,10 @@ export default function Calendar() { try { const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId }); setSharedUsers(res.data.sharedUsers); + setPendingInvitations(res.data.pendingInvitations || []); setShareSearch(''); setShareResults([]); - toast.success(t('calendar.shareAdded')); + toast.success(t('calendar.invitationSent')); } catch (err) { toast.error(err.response?.data?.error || t('calendar.shareFailed')); } @@ -303,10 +308,21 @@ export default function Calendar() { try { const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`); setSharedUsers(res.data.sharedUsers); + setPendingInvitations(res.data.pendingInvitations || []); toast.success(t('calendar.shareRemoved')); } catch { toast.error(t('calendar.shareFailed')); } }; + const handleCancelInvitation = async (userId) => { + if (!showShare) return; + try { + const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`); + setSharedUsers(res.data.sharedUsers); + setPendingInvitations(res.data.pendingInvitations || []); + toast.success(t('calendar.invitationCancelled')); + } catch { toast.error(t('calendar.shareFailed')); } + }; + const handleFedSend = async (e) => { e.preventDefault(); if (!showFedShare) return; @@ -721,8 +737,36 @@ export default function Calendar() { )}
+ {pendingInvitations.length > 0 && ( +
+

{t('calendar.pendingInvitations')}

+ {pendingInvitations.map(u => ( +
+
+
+ {(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)} +
+
+
{u.display_name || u.name}
+
{t('calendar.invitationPending')}
+
+
+ +
+ ))} +
+ )} + {sharedUsers.length > 0 && (
+ {pendingInvitations.length > 0 && ( +

{t('calendar.accepted')}

+ )} {sharedUsers.map(u => (
diff --git a/src/pages/FederationInbox.jsx b/src/pages/FederationInbox.jsx index 2c15ba0..bb6d717 100644 --- a/src/pages/FederationInbox.jsx +++ b/src/pages/FederationInbox.jsx @@ -8,16 +8,19 @@ export default function FederationInbox() { const { t } = useLanguage(); const [invitations, setInvitations] = useState([]); const [calendarInvitations, setCalendarInvitations] = useState([]); + const [localCalInvitations, setLocalCalInvitations] = useState([]); const [loading, setLoading] = useState(true); const fetchInvitations = async () => { try { - const [roomRes, calRes] = await Promise.all([ + const [roomRes, calRes, localCalRes] = await Promise.all([ api.get('/federation/invitations'), api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })), + api.get('/calendar/local-invitations').catch(() => ({ data: { invitations: [] } })), ]); setInvitations(roomRes.data.invitations || []); setCalendarInvitations(calRes.data.invitations || []); + setLocalCalInvitations(localCalRes.data.invitations || []); } catch { toast.error(t('federation.loadFailed')); } finally { @@ -91,6 +94,37 @@ export default function FederationInbox() { } }; + // ── Local calendar invitation actions ─────────────────────────────────── + const handleLocalCalAccept = async (id) => { + try { + await api.post(`/calendar/local-invitations/${id}/accept`); + toast.success(t('federation.calendarLocalAccepted')); + fetchInvitations(); + } catch { + toast.error(t('federation.acceptFailed')); + } + }; + + const handleLocalCalDecline = async (id) => { + try { + await api.delete(`/calendar/local-invitations/${id}`); + toast.success(t('federation.declined')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + + const handleLocalCalDelete = async (id) => { + try { + await api.delete(`/calendar/local-invitations/${id}`); + toast.success(t('federation.invitationRemoved')); + fetchInvitations(); + } catch { + toast.error(t('federation.declineFailed')); + } + }; + if (loading) { return (
@@ -103,9 +137,11 @@ export default function FederationInbox() { const pastRooms = invitations.filter(i => i.status !== 'pending'); const pendingCal = calendarInvitations.filter(i => i.status === 'pending'); const pastCal = calendarInvitations.filter(i => i.status !== 'pending'); + const pendingLocalCal = localCalInvitations.filter(i => i.status === 'pending'); + const pastLocalCal = localCalInvitations.filter(i => i.status !== 'pending'); - const totalPending = pendingRooms.length + pendingCal.length; - const totalPast = pastRooms.length + pastCal.length; + const totalPending = pendingRooms.length + pendingCal.length + pendingLocalCal.length; + const totalPast = pastRooms.length + pastCal.length + pastLocalCal.length; return (
@@ -196,6 +232,45 @@ export default function FederationInbox() {
))} + + {/* Pending local calendar invitations */} + {pendingLocalCal.map(inv => ( +
+
+
+
+ + + {t('federation.localCalendarEvent')} + +

{inv.title}

+
+

+ {t('federation.from')}: {inv.from_name} +

+

+ {new Date(inv.start_time).toLocaleString()} – {new Date(inv.end_time).toLocaleString()} +

+ {inv.description && ( +

"{inv.description}"

+ )} +

+ {new Date(inv.created_at).toLocaleString()} +

+
+
+ + +
+
+
+ ))}
)} @@ -284,6 +359,36 @@ export default function FederationInbox() {
))} + + {/* Past local calendar invitations */} + {pastLocalCal.map(inv => ( +
+
+
+ +
+

{inv.title}

+

{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}

+
+
+
+ + {inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')} + + +
+
+
+ ))} )}