feat: implement calendar invitation system with creation, acceptance, and management features
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s

This commit is contained in:
2026-03-02 13:57:56 +01:00
parent 62a3812424
commit d989e1291d
6 changed files with 328 additions and 43 deletions

View File

@@ -514,6 +514,50 @@ export async function initDatabase() {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
}
// Calendar invitations (federated calendar events that must be accepted first)
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id SERIAL PRIMARY KEY,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_invitations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_uid TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
join_url TEXT,
from_user TEXT NOT NULL,
to_user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(event_uid, to_user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_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

@@ -413,13 +413,13 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate
const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]);
if (existing) return res.json({ success: true, message: 'Event already received' });
// Check duplicate (already in invitations or already accepted into calendar)
const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]);
if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' });
// Create event for the target user
// Store as pending invitation — user must accept before it appears in calendar
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
event_uid,
@@ -428,10 +428,10 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
start_time,
end_time,
room_uid || null,
join_url || null,
from_user,
targetUser.id,
'#6366f1',
from_user,
join_url || null,
]);
res.json({ success: true });

View File

@@ -261,12 +261,20 @@ router.get('/invitations', authenticateToken, async (req, res) => {
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
try {
const db = getDb();
const result = await db.get(
const roomResult = await db.get(
`SELECT COUNT(*) as count FROM federation_invitations
WHERE to_user_id = ? AND status = 'pending'`,
[req.user.id]
);
res.json({ count: result?.count || 0 });
let calResult = { count: 0 };
try {
calResult = await db.get(
`SELECT COUNT(*) as count FROM calendar_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) });
} catch (err) {
res.json({ count: 0 });
}
@@ -338,6 +346,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
}
});
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
try {
const db = getDb();
const invitations = await db.all(
`SELECT * FROM calendar_invitations
WHERE to_user_id = ?
ORDER BY created_at DESC`,
[req.user.id]
);
res.json({ invitations });
} catch (err) {
log.federation.error('List calendar invitations error:', err);
res.status(500).json({ error: 'Failed to load calendar invitations' });
}
});
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
await db.run(
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
[inv.id]
);
// Check if event was already previously accepted (duplicate guard)
const existing = await db.get(
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
[inv.event_uid, req.user.id]
);
if (!existing) {
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
inv.event_uid,
inv.title,
inv.description || null,
inv.start_time,
inv.end_time,
inv.room_uid || null,
req.user.id,
inv.color || '#6366f1',
inv.from_user,
inv.join_url || null,
]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Accept calendar invitation error:', err);
res.status(500).json({ error: 'Failed to accept calendar invitation' });
}
});
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const inv = await db.get(
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
[req.params.id, req.user.id]
);
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
if (inv.status === 'pending') {
// mark as declined
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
} else {
// accepted or declined — permanently remove from inbox
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
}
res.json({ success: true });
} catch (err) {
log.federation.error('Delete calendar invitation error:', err);
res.status(500).json({ error: 'Failed to remove calendar invitation' });
}
});
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
router.get('/federated-rooms', authenticateToken, async (req, res) => {
try {