v0.9.88 major change sqlite to postgres
This commit is contained in:
@@ -1,396 +1,378 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../models/db');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
const multer = require('multer');
|
||||
const multer = require('multer');
|
||||
const { parse: csvParse } = require('csv-parse/sync');
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 2 * 1024 * 1024 } });
|
||||
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(`
|
||||
async function isToolManagerFn(schema, user) {
|
||||
if (user.role === 'admin') return true;
|
||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
|
||||
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
|
||||
if (!groupIds.length) return false;
|
||||
const ph = groupIds.map((_,i) => `$${i+2}`).join(',');
|
||||
return !!(await queryOne(schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [user.id, ...groupIds]));
|
||||
}
|
||||
|
||||
async function canViewEvent(schema, event, userId, isToolManager) {
|
||||
if (isToolManager || event.is_public) return true;
|
||||
const assigned = await queryOne(schema, `
|
||||
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);
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [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);
|
||||
async function enrichEvent(schema, event) {
|
||||
event.event_type = event.event_type_id
|
||||
? await queryOne(schema, 'SELECT * FROM event_types WHERE id=$1', [event.event_type_id])
|
||||
: null;
|
||||
// recurrence_rule is JSONB in Postgres — already parsed, no need to JSON.parse
|
||||
event.user_groups = await query(schema, `
|
||||
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=$1
|
||||
`, [event.id]);
|
||||
return event;
|
||||
}
|
||||
|
||||
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;
|
||||
if (event.recurrence_rule && typeof event.recurrence_rule === 'string') {
|
||||
try { event.recurrence_rule = JSON.parse(event.recurrence_rule); } catch(e) { event.recurrence_rule = null; }
|
||||
async function applyEventUpdate(schema, eventId, fields, userGroupIds) {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent } = fields;
|
||||
await exec(schema, `
|
||||
UPDATE events SET
|
||||
title = COALESCE($1, title),
|
||||
event_type_id = $2,
|
||||
start_at = COALESCE($3, start_at),
|
||||
end_at = COALESCE($4, end_at),
|
||||
all_day = COALESCE($5, all_day),
|
||||
location = $6,
|
||||
description = $7,
|
||||
is_public = COALESCE($8, is_public),
|
||||
track_availability = COALESCE($9, track_availability),
|
||||
recurrence_rule = $10,
|
||||
updated_at = NOW()
|
||||
WHERE id = $11
|
||||
`, [
|
||||
title?.trim() || null,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : origEvent.event_type_id,
|
||||
startAt || null,
|
||||
endAt || null,
|
||||
allDay !== undefined ? allDay : null,
|
||||
location !== undefined ? (location || null) : origEvent.location,
|
||||
description !== undefined ? (description || null) : origEvent.description,
|
||||
isPublic !== undefined ? isPublic : null,
|
||||
trackAvailability !== undefined ? trackAvailability : null,
|
||||
recurrenceRule !== undefined ? recurrenceRule : origEvent.recurrence_rule,
|
||||
eventId,
|
||||
]);
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
await exec(schema, 'DELETE FROM event_user_groups WHERE event_id=$1', [eventId]);
|
||||
for (const ugId of userGroupIds)
|
||||
await exec(schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
}
|
||||
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.get('/event-types', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await query(req.schema, 'SELECT * FROM event_types ORDER BY is_default DESC, name ASC');
|
||||
res.json({ eventTypes });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/event-types', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
router.post('/event-types', authMiddleware, teamManagerMiddleware, async (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) });
|
||||
try {
|
||||
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [name.trim()]))
|
||||
return res.status(400).json({ error: 'Event type with that name already exists' });
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO event_types (name,colour,default_user_group_id,default_duration_hrs) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||
[name.trim(), colour||'#6366f1', defaultUserGroupId||null, defaultDurationHrs||1.0]
|
||||
);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [r.rows[0].id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
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_protected) return res.status(403).json({ error: 'Cannot edit a protected 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.patch('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_protected) return res.status(403).json({ error: 'Cannot edit a protected event type' });
|
||||
const { name, colour, defaultUserGroupId, defaultDurationHrs } = req.body;
|
||||
if (name && name.trim() !== et.name) {
|
||||
if (await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1) AND id!=$2', [name.trim(), et.id]))
|
||||
return res.status(400).json({ error: 'Name already in use' });
|
||||
}
|
||||
await exec(req.schema, `
|
||||
UPDATE event_types SET
|
||||
name = COALESCE($1, name),
|
||||
colour = COALESCE($2, colour),
|
||||
default_user_group_id = $3,
|
||||
default_duration_hrs = COALESCE($4, default_duration_hrs)
|
||||
WHERE id=$5
|
||||
`, [name?.trim()||null, colour||null, defaultUserGroupId??et.default_user_group_id, defaultDurationHrs||null, et.id]);
|
||||
res.json({ eventType: await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [et.id]) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
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 || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected 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 });
|
||||
router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const et = await queryOne(req.schema, 'SELECT * FROM event_types WHERE id=$1', [req.params.id]);
|
||||
if (!et) return res.status(404).json({ error: 'Not found' });
|
||||
if (et.is_default || et.is_protected) return res.status(403).json({ error: 'Cannot delete a protected event type' });
|
||||
await exec(req.schema, 'UPDATE events SET event_type_id=NULL WHERE event_type_id=$1', [et.id]);
|
||||
await exec(req.schema, 'DELETE FROM event_types WHERE id=$1', [et.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── 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);
|
||||
// Include current user's response so the list can show the awaiting indicator
|
||||
const mine = db.prepare('SELECT response FROM event_availability WHERE event_id = ? AND user_id = ?').get(e.id, req.user.id);
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const { from, to } = req.query;
|
||||
let sql = 'SELECT * FROM events WHERE 1=1';
|
||||
const params = [];
|
||||
let pi = 1;
|
||||
if (from) { sql += ` AND end_at >= $${pi++}`; params.push(from); }
|
||||
if (to) { sql += ` AND start_at <= $${pi++}`; params.push(to); }
|
||||
sql += ' ORDER BY start_at ASC';
|
||||
const rawEvents = await query(req.schema, sql, params);
|
||||
const events = [];
|
||||
for (const e of rawEvents) {
|
||||
if (!(await canViewEvent(req.schema, e, req.user.id, itm))) continue;
|
||||
await enrichEvent(req.schema, e);
|
||||
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [e.id, req.user.id]);
|
||||
e.my_response = mine?.response || null;
|
||||
return e;
|
||||
});
|
||||
res.json({ events });
|
||||
events.push(e);
|
||||
}
|
||||
res.json({ events });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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 });
|
||||
router.get('/me/pending', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const pending = await query(req.schema, `
|
||||
SELECT DISTINCT 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=$1 AND e.track_availability=TRUE
|
||||
AND e.end_at >= NOW()
|
||||
AND NOT EXISTS (SELECT 1 FROM event_availability ea WHERE ea.event_id=e.id AND ea.user_id=$1)
|
||||
ORDER BY e.start_at ASC
|
||||
`, [req.user.id]);
|
||||
const result = [];
|
||||
for (const e of pending) result.push(await enrichEvent(req.schema, e));
|
||||
res.json({ events: result });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create event
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds = [], recurrenceRule } = req.body;
|
||||
router.get('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
||||
await enrichEvent(req.schema, event);
|
||||
if (event.track_availability && itm) {
|
||||
event.availability = await query(req.schema, `
|
||||
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=$1
|
||||
`, [req.params.id]);
|
||||
const assignedIds = (await query(req.schema, `
|
||||
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=$1
|
||||
`, [req.params.id])).map(r => r.user_id);
|
||||
const respondedIds = new Set(event.availability.map(r => r.user_id));
|
||||
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length;
|
||||
}
|
||||
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
event.my_response = mine?.response || null;
|
||||
res.json({ event });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = 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, recurrence_rule, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
title.trim(), eventTypeId || null, startAt, endAt,
|
||||
allDay ? 1 : 0, location || null, description || null,
|
||||
isPublic !== false ? 1 : 0, trackAvailability ? 1 : 0,
|
||||
recurrenceRule ? JSON.stringify(recurrenceRule) : null, 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) });
|
||||
try {
|
||||
const r = await queryResult(req.schema, `
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
const eventId = r.rows[0].id;
|
||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
res.json({ event: await enrichEvent(req.schema, event) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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, recurrenceRule, recurringScope } = 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),
|
||||
recurrence_rule = ?,
|
||||
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,
|
||||
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
|
||||
req.params.id
|
||||
);
|
||||
// For recurring events: if scope='future', update all future occurrences too
|
||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||
const futureEvents = db.prepare(`
|
||||
SELECT id FROM events
|
||||
WHERE id != ? AND created_by = ? AND recurrence_rule IS NOT NULL
|
||||
AND start_at >= ? AND title = ?
|
||||
`).all(req.params.id, event.created_by, event.start_at, event.title);
|
||||
for (const fe of futureEvents) {
|
||||
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),
|
||||
recurrence_rule = ?,
|
||||
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,
|
||||
recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule,
|
||||
fe.id
|
||||
);
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(fe.id);
|
||||
for (const ugId of userGroupIds)
|
||||
db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(fe.id, ugId);
|
||||
}
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||
|
||||
// Recurring future scope — update all future occurrences
|
||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||
const futureEvents = await query(req.schema, `
|
||||
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
||||
AND start_at >= $3 AND title=$4
|
||||
`, [req.params.id, event.created_by, event.start_at, event.title]);
|
||||
for (const fe of futureEvents)
|
||||
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
// Find which groups are being removed
|
||||
const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?')
|
||||
.all(req.params.id).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
|
||||
// Remove availability responses for users who are only in removed groups
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUserIds = db.prepare('SELECT user_id FROM user_group_members WHERE user_group_id = ?')
|
||||
.all(removedGid).map(r => r.user_id);
|
||||
for (const uid of removedUserIds) {
|
||||
// Check if user is still in ANY remaining group for this event
|
||||
const stillAssigned = newGroupSet.size > 0 && db.prepare(`
|
||||
SELECT 1 FROM user_group_members
|
||||
WHERE user_id = ? AND user_group_id IN (${[...newGroupSet].map(()=>'?').join(',')})
|
||||
`).get(uid, ...[...newGroupSet]);
|
||||
if (!stillAssigned) {
|
||||
db.prepare('DELETE FROM event_availability WHERE event_id = ? AND user_id = ?')
|
||||
.run(req.params.id, uid);
|
||||
// Clean up availability for users removed from groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
|
||||
for (const uid of removedUids) {
|
||||
if (newGroupSet.size > 0) {
|
||||
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||
if (stillAssigned) continue;
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) });
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
res.json({ event: await enrichEvent(req.schema, updated) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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 });
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── 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 });
|
||||
router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [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' });
|
||||
const { response } = req.body;
|
||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
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=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [event.id, req.user.id, response]);
|
||||
res.json({ success: true, response });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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 });
|
||||
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// 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 }]
|
||||
router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||
const { responses } = req.body;
|
||||
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 });
|
||||
try {
|
||||
let saved = 0;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
for (const { eventId, response } of responses) {
|
||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event || !event.track_availability) continue;
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
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=$1 AND ugm.user_id=$2
|
||||
`, [eventId, req.user.id]);
|
||||
if (!inGroup && !itm) continue;
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [eventId, req.user.id, response]);
|
||||
saved++;
|
||||
}
|
||||
res.json({ success: true, saved });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── CSV Bulk Import ───────────────────────────────────────────────────────────
|
||||
// ── CSV Import ────────────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), (req, res) => {
|
||||
router.post('/import/preview', authMiddleware, teamManagerMiddleware, upload.single('file'), async (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 rows = csvParse(req.file.buffer.toString('utf8'), { columns:true, skip_empty_lines:true, trim:true });
|
||||
const results = await Promise.all(rows.map(async (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 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 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 };
|
||||
|
||||
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 };
|
||||
});
|
||||
const endMs = new Date(startAt).getTime() + durHrs * 3600000;
|
||||
const endAt = isNaN(endMs) ? startAt : new Date(endMs).toISOString().slice(0,19);
|
||||
const dup = await queryOne(req.schema, 'SELECT id,title FROM events WHERE title=$1 AND start_at=$2', [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)
|
||||
router.post('/import/confirm', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { rows } = req.body;
|
||||
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; }
|
||||
try {
|
||||
let imported = 0;
|
||||
const colours = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#8b5cf6','#ec4899'];
|
||||
for (const row of rows) {
|
||||
if (row.error || row.skip) continue;
|
||||
let typeId = null;
|
||||
if (row.typeName) {
|
||||
let et = await queryOne(req.schema, 'SELECT id FROM event_types WHERE LOWER(name)=LOWER($1)', [row.typeName]);
|
||||
if (!et) {
|
||||
const usedColours = (await query(req.schema, 'SELECT colour FROM event_types')).map(r => r.colour);
|
||||
const colour = colours.find(c => !usedColours.includes(c)) || '#' + Math.floor(Math.random()*0xffffff).toString(16).padStart(6,'0');
|
||||
const cr = await queryResult(req.schema, 'INSERT INTO event_types (name,colour) VALUES ($1,$2) RETURNING id', [row.typeName, colour]);
|
||||
typeId = cr.rows[0].id;
|
||||
} else { typeId = et.id; }
|
||||
}
|
||||
await exec(req.schema,
|
||||
'INSERT INTO events (title,event_type_id,start_at,end_at,location,is_public,track_availability,created_by) VALUES ($1,$2,$3,$4,$5,TRUE,FALSE,$6)',
|
||||
[row.title, typeId, row.startAt, row.endAt, row.location||null, req.user.id]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
stmt.run(row.title, typeId, row.startAt, row.endAt, row.location || null, req.user.id);
|
||||
imported++;
|
||||
}
|
||||
res.json({ success: true, imported });
|
||||
res.json({ success: true, imported });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user