feat: implement calendar invitation system with creation, acceptance, and management features
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user