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:
+
+
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 && (
+