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,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}`);
|
||||
|
||||
Reference in New Issue
Block a user