feat(calendar): implement local calendar invitations with email notifications
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m19s
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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user