feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s

- Added functionality to create, accept, decline, and delete local calendar invitations.
- Integrated email notifications for calendar event invitations and deletions.
- Updated database schema to support local invitations and outbound event tracking.
- Enhanced the calendar UI to display pending invitations and allow users to manage them.
- Localized new strings for invitations in English and German.
This commit is contained in:
2026-03-02 14:37:54 +01:00
parent d989e1291d
commit c2c10f9a4b
10 changed files with 606 additions and 18 deletions

View File

@@ -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) {

View File

@@ -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: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
@@ -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: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#cba6f7;margin-top:0;">Hey ${safeName} 👋</h2>
<p>You have received a calendar invitation from <strong style="color:#cdd6f4;">${safeFromUser}</strong>.</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
<a href="${inboxUrl}"
style="display:inline-block;background:#cba6f7;color:#1e1e2e;padding:12px 32px;border-radius:8px;text-decoration:none;font-weight:bold;">
View Invitation
</a>
</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">Open the link above to accept or decline the invitation.</p>
</div>
`,
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: `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:32px;background:#1e1e2e;color:#cdd6f4;border-radius:12px;">
<h2 style="color:#f38ba8;margin-top:0;">Hey ${safeName} 👋</h2>
<p>The following calendar event was deleted by the organiser (<strong style="color:#cdd6f4;">${safeFromUser}</strong>) and is no longer available:</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;border-left:4px solid #f38ba8;">
<p style="margin:0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
</div>
<p style="font-size:13px;color:#7f849c;">The event has been automatically removed from your calendar.</p>
<hr style="border:none;border-top:1px solid #313244;margin:24px 0;"/>
<p style="font-size:12px;color:#585b70;">This message was sent automatically by ${escapeHtml(appName)}.</p>
</div>
`,
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

View File

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

View File

@@ -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) => {