v0.9.46 Add event scheduler

This commit is contained in:
2026-03-17 09:48:09 -04:00
parent 3c62782a8d
commit fed5e75122
10 changed files with 1293 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "jama-backend",
"version": "0.9.45",
"version": "0.9.46",
"description": "TeamChat backend server",
"main": "src/index.js",
"scripts": {
@@ -19,7 +19,8 @@
"sharp": "^0.33.2",
"socket.io": "^4.6.1",
"web-push": "^3.6.7",
"better-sqlite3-multiple-ciphers": "^12.6.2"
"better-sqlite3-multiple-ciphers": "^12.6.2",
"csv-parse": "^5.5.6"
},
"devDependencies": {
"nodemon": "^3.0.2"

View File

@@ -42,6 +42,7 @@ app.use('/api/users', require('./routes/users'));
app.use('/api/groups', require('./routes/groups')(io));
app.use('/api/messages', require('./routes/messages')(io));
app.use('/api/usergroups', require('./routes/usergroups')(io));
app.use('/api/schedule', require('./routes/schedule'));
app.use('/api/settings', require('./routes/settings'));
app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));

View File

@@ -381,6 +381,57 @@ function initDb() {
console.log('[DB] Migration: user_groups tables ready');
} catch (e) { console.error('[DB] user_groups migration error:', e.message); }
// ── Schedule Manager ────────────────────────────────────────────────────────
try {
db.exec(`
CREATE TABLE IF NOT EXISTS event_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER,
default_duration_hrs REAL NOT NULL DEFAULT 1.0,
is_default INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
event_type_id INTEGER,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
location TEXT,
description TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
track_availability INTEGER NOT NULL DEFAULT 0,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS event_user_groups (
event_id INTEGER NOT NULL,
user_group_id INTEGER NOT NULL,
PRIMARY KEY (event_id, user_group_id),
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS event_availability (
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (event_id, user_id),
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`);
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default) VALUES ('Default', '#9ca3af', 1)").run();
console.log('[DB] Schedule Manager tables ready');
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
console.log('[DB] Schema initialized');
return db;
}

View File

@@ -0,0 +1,327 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../models/db');
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
// ── Helpers ───────────────────────────────────────────────────────────────────
function canViewEvent(db, event, userId, isToolManager) {
if (isToolManager) return true;
if (event.is_public) return true;
// Private: user must be in an assigned user group
const assigned = db.prepare(`
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, userId);
return !!assigned;
}
function isToolManagerFn(db, user) {
if (user.role === 'admin') return true;
const tmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_tool_managers'").get();
const gmSetting = db.prepare("SELECT value FROM settings WHERE key = 'team_group_managers'").get();
const groupIds = [...new Set([
...JSON.parse(tmSetting?.value || '[]'),
...JSON.parse(gmSetting?.value || '[]'),
])];
if (!groupIds.length) return false;
return !!db.prepare(`SELECT 1 FROM user_group_members WHERE user_id = ? AND user_group_id IN (${groupIds.map(()=>'?').join(',')})`).get(user.id, ...groupIds);
}
function enrichEvent(db, event) {
event.event_type = event.event_type_id
? db.prepare('SELECT * FROM event_types WHERE id = ?').get(event.event_type_id)
: null;
event.user_groups = db.prepare(`
SELECT ug.id, ug.name FROM event_user_groups eug
JOIN user_groups ug ON ug.id = eug.user_group_id
WHERE eug.event_id = ?
`).all(event.id);
return event;
}
// ── Event Types ───────────────────────────────────────────────────────────────
router.get('/event-types', authMiddleware, (req, res) => {
const db = getDb();
res.json({ eventTypes: db.prepare('SELECT * FROM event_types ORDER BY is_default DESC, name ASC').all() });
});
router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => {
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
const db = getDb();
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(name.trim())) {
return res.status(400).json({ error: 'Event type with that name already exists' });
}
const r = db.prepare(`INSERT INTO event_types (name, colour, default_user_group_id, default_duration_hrs)
VALUES (?, ?, ?, ?)`).run(name.trim(), colour || '#6366f1', defaultUserGroupId || null, defaultDurationHrs || 1.0);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(r.lastInsertRowid) });
});
router.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default) return res.status(403).json({ error: 'Cannot edit the Default event type' });
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
if (name && name.trim() !== et.name) {
if (db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), et.id))
return res.status(400).json({ error: 'Name already in use' });
}
db.prepare(`UPDATE event_types SET
name = COALESCE(?, name),
colour = COALESCE(?, colour),
default_user_group_id = ?,
default_duration_hrs = COALESCE(?, default_duration_hrs)
WHERE id = ?`).run(name?.trim() || null, colour || null, defaultUserGroupId ?? et.default_user_group_id, defaultDurationHrs || null, et.id);
res.json({ eventType: db.prepare('SELECT * FROM event_types WHERE id = ?').get(et.id) });
});
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const et = db.prepare('SELECT * FROM event_types WHERE id = ?').get(req.params.id);
if (!et) return res.status(404).json({ error: 'Not found' });
if (et.is_default) return res.status(403).json({ error: 'Cannot delete the Default event type' });
// Null out event_type_id on events using this type
db.prepare('UPDATE events SET event_type_id = NULL WHERE event_type_id = ?').run(et.id);
db.prepare('DELETE FROM event_types WHERE id = ?').run(et.id);
res.json({ success: true });
});
// ── Events ────────────────────────────────────────────────────────────────────
// List events (with optional date range filter)
router.get('/', authMiddleware, (req, res) => {
const db = getDb();
const itm = isToolManagerFn(db, req.user);
const { from, to } = req.query;
let q = 'SELECT * FROM events WHERE 1=1';
const params = [];
if (from) { q += ' AND end_at >= ?'; params.push(from); }
if (to) { q += ' AND start_at <= ?'; params.push(to); }
q += ' ORDER BY start_at ASC';
const events = db.prepare(q).all(...params)
.filter(e => canViewEvent(db, e, req.user.id, itm))
.map(e => enrichEvent(db, e));
res.json({ events });
});
// Get single event
router.get('/:id', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const itm = isToolManagerFn(db, req.user);
if (!canViewEvent(db, event, req.user.id, itm)) return res.status(403).json({ error: 'Access denied' });
enrichEvent(db, event);
// Availability (only for assigned group members / tool managers)
if (event.track_availability && itm) {
const responses = db.prepare(`
SELECT ea.response, ea.updated_at, u.id as user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id = ea.user_id
WHERE ea.event_id = ?
`).all(req.params.id);
event.availability = responses;
// Count no-response: users in assigned groups who haven't responded
const assignedUserIds = db.prepare(`
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ?
`).all(req.params.id).map(r => r.user_id);
const respondedIds = new Set(responses.map(r => r.user_id));
event.no_response_count = assignedUserIds.filter(id => !respondedIds.has(id)).length;
}
// Current user's own response
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(req.params.id, req.user.id);
event.my_response = mine?.response || null;
res.json({ event });
});
// Create event
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [] } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
const db = getDb();
const r = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, all_day, location, description, is_public, track_availability, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
title.trim(), eventTypeId || null, startAt, endAt,
allDay ? 1 : 0, location || null, description || null,
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0, req.user.id
);
const eventId = r.lastInsertRowid;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(eventId, ugId);
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
res.json({ event: enrichEvent(db, event) });
});
// Update event
router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds } = req.body;
db.prepare(`UPDATE events SET
title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at),
end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day),
location = ?, description = ?, is_public = COALESCE(?, is_public),
track_availability = COALESCE(?, track_availability), updated_at = datetime('now')
WHERE id = ?`).run(
title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null,
location !== undefined ? (location || null) : event.location,
description !== undefined ? (description || null) : event.description,
isPublic !== undefined ? (isPublic ? 1 : 0) : null,
trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null,
req.params.id
);
if (Array.isArray(userGroupIds)) {
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(req.params.id);
for (const ugId of userGroupIds)
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(req.params.id, ugId);
}
const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
res.json({ event: enrichEvent(db, updated) });
});
// Delete event
router.delete('/:id', authMiddleware, teamManagerMiddleware, (req, res) => {
const db = getDb();
if (!db.prepare('SELECT id FROM events WHERE id = ?').get(req.params.id)) return res.status(404).json({ error: 'Not found' });
db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ── Availability ──────────────────────────────────────────────────────────────
// Submit/update availability
router.put('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled for this event' });
const { response } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
// User must be in an assigned group
const inGroup = db.prepare(`
SELECT 1 FROM event_user_groups eug
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE eug.event_id = ? AND ugm.user_id = ?
`).get(event.id, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')
`).run(event.id, req.user.id, response, response);
res.json({ success: true, response });
});
// Delete availability (withdraw response)
router.delete('/:id/availability', authMiddleware, (req, res) => {
const db = getDb();
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?').run(req.params.id, req.user.id);
res.json({ success: true });
});
// Get pending availability for current user (events they need to respond to)
router.get('/me/pending', authMiddleware, (req, res) => {
const db = getDb();
const pending = db.prepare(`
SELECT e.* FROM events e
JOIN event_user_groups eug ON eug.event_id = e.id
JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id
WHERE ugm.user_id = ? AND e.track_availability = 1
AND e.end_at >= datetime('now')
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id = e.id AND ea.user_id = ?)
ORDER BY e.start_at ASC
`).all(req.user.id, req.user.id);
res.json({ events: pending.map(e => enrichEvent(db, e)) });
});
// Bulk availability response
router.post('/me/bulk-availability', authMiddleware, (req, res) => {
const { responses } = req.body; // [{ eventId, response }]
if (!Array.isArray(responses)) return res.status(400).json({ error: 'responses array required' });
const db = getDb();
const stmt = db.prepare(`INSERT INTO event_availability (event_id, user_id, response, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(event_id, user_id) DO UPDATE SET response = ?, updated_at = datetime('now')`);
let saved = 0;
for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue;
const event = db.prepare('SELECT * FROM events WHERE id = ?').get(eventId);
if (!event || !event.track_availability) continue;
const inGroup = db.prepare(`SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id = eug.user_group_id WHERE eug.event_id = ? AND ugm.user_id = ?`).get(eventId, req.user.id);
const itm = isToolManagerFn(db, req.user);
if (!inGroup && !itm) continue;
stmt.run(eventId, req.user.id, response, response);
saved++;
}
res.json({ success: true, saved });
});
// ── CSV Bulk Import ───────────────────────────────────────────────────────────
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
try {
const rows = csvParse(req.file.buffer.toString('utf8'), { columns: true, skip_empty_lines: true, trim: true });
const db = getDb();
const results = rows.map((row, i) => {
const title = row['Event Title'] || row['event_title'] || row['title'] || '';
const startDate = row['start_date'] || row['Start Date'] || '';
const startTime = row['start_time'] || row['Start Time'] || '09:00';
const location = row['event_location'] || row['location'] || '';
const typeName = row['event_type'] || row['Event Type'] || 'Default';
const durHrs = parseFloat(row['default_duration'] || row['duration'] || '1') || 1;
if (!title || !startDate) return { row: i + 1, title, error: 'Missing title or start date', duplicate: false };
const startAt = `${startDate}T${startTime.padStart(5,'0')}:00`;
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
// Check duplicate
const dup = db.prepare('SELECT id, title FROM events WHERE title = ? AND start_at = ?').get(title, startAt);
return { row: i+1, title, startAt, endAt, location, typeName, durHrs, duplicate: !!dup, duplicateId: dup?.id, error: null };
});
res.json({ rows: results });
} catch (e) { res.status(400).json({ error: 'CSV parse error: ' + e.message }); }
});
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, (req, res) => {
const { rows } = req.body; // filtered rows from preview (client excludes skipped)
if (!Array.isArray(rows)) return res.status(400).json({ error: 'rows array required' });
const db = getDb();
let imported = 0;
const stmt = db.prepare(`INSERT INTO events (title, event_type_id, start_at, end_at, location, is_public, track_availability, created_by)
VALUES (?, ?, ?, ?, ?, 1, 0, ?)`);
for (const row of rows) {
if (row.error || row.skip) continue;
let typeId = null;
if (row.typeName) {
let et = db.prepare('SELECT id FROM event_types WHERE LOWER(name) = LOWER(?)').get(row.typeName);
if (!et) {
// Create missing type with random colour
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
const usedColours = db.prepare('SELECT colour FROM event_types').all().map(r => r.colour);
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
const r2 = db.prepare('INSERT INTO event_types (name, colour) VALUES (?, ?)').run(row.typeName, colour);
typeId = r2.lastInsertRowid;
} else { typeId = et.id; }
}
stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id);
imported++;
}
res.json({ success: true, imported });
});
module.exports = router;