Participants, context menus, budget rename, file types, UI polish
- Assignment participants: toggle who joins each activity - Chips with hover-to-remove (strikethrough effect) - Add button with dropdown for available members - Avatars in day plan sidebar - Side-by-side with reservation in place inspector - Right-click context menus for places, notes in day plan + places list - Budget categories can now be renamed (pencil icon inline edit) - Admin: configurable allowed file types (stored in app_settings) - File manager shows allowed types dynamically - Hotel picker: select place + save button (no auto-close) - Edit pencil opens full hotel popup with all options - Place inspector: opening hours + files side by side on desktop - Address clamped to 2 lines, coordinates hidden on mobile - Category shows icon only on mobile - Rating hidden on mobile in place inspector - Time validation: "10" becomes "10:00" - Climate weather: full hourly data from archive API - CustomSelect: grouped headers support (isHeader) - Various responsive fixes
This commit is contained in:
@@ -337,6 +337,14 @@ function initDb() {
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assignment_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(assignment_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
|
||||
`);
|
||||
|
||||
// Versioned migrations — each runs exactly once
|
||||
@@ -438,6 +446,17 @@ function initDb() {
|
||||
() => {
|
||||
try { _db.exec('ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL'); } catch {}
|
||||
},
|
||||
// 24: Assignment participants (who's joining which activity)
|
||||
() => {
|
||||
_db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS assignment_participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
UNIQUE(assignment_id, user_id)
|
||||
)
|
||||
`);
|
||||
},
|
||||
// Future migrations go here (append only, never reorder)
|
||||
];
|
||||
|
||||
|
||||
@@ -30,11 +30,19 @@ function getAssignmentWithPlace(assignmentId) {
|
||||
WHERE pt.place_id = ?
|
||||
`).all(a.place_id);
|
||||
|
||||
const participants = db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(a.id);
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
participants,
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
@@ -105,12 +113,25 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// Load all participants for this day's assignments in one query
|
||||
const assignmentIds = assignments.map(a => a.id)
|
||||
const allParticipants = assignmentIds.length > 0
|
||||
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${assignmentIds.map(() => '?').join(',')})`)
|
||||
.all(...assignmentIds)
|
||||
: []
|
||||
const participantsByAssignment = {}
|
||||
for (const p of allParticipants) {
|
||||
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
|
||||
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
|
||||
}
|
||||
|
||||
const result = assignments.map(a => {
|
||||
return {
|
||||
id: a.id,
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
participants: participantsByAssignment[a.id] || [],
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
@@ -241,4 +262,45 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => {
|
||||
broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
// GET /api/trips/:tripId/assignments/:id/participants
|
||||
router.get('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const participants = db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(id);
|
||||
|
||||
res.json({ participants });
|
||||
});
|
||||
|
||||
// PUT /api/trips/:tripId/assignments/:id/participants — set participants (replace all)
|
||||
router.put('/trips/:tripId/assignments/:id/participants', authenticate, (req, res) => {
|
||||
const { tripId, id } = req.params;
|
||||
if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
|
||||
|
||||
const { user_ids } = req.body; // array of user IDs, empty array = everyone
|
||||
if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
|
||||
|
||||
// Delete existing and insert new
|
||||
db.prepare('DELETE FROM assignment_participants WHERE assignment_id = ?').run(id);
|
||||
if (user_ids.length > 0) {
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)');
|
||||
for (const userId of user_ids) insert.run(id, userId);
|
||||
}
|
||||
|
||||
const participants = db.prepare(`
|
||||
SELECT ap.user_id, u.username, u.avatar
|
||||
FROM assignment_participants ap
|
||||
JOIN users u ON ap.user_id = u.id
|
||||
WHERE ap.assignment_id = ?
|
||||
`).all(id);
|
||||
|
||||
res.json({ participants });
|
||||
broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id']);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -80,6 +80,7 @@ router.get('/app-config', (req, res) => {
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
allowed_file_types: db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get()?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@nomad.app' : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
@@ -368,10 +369,13 @@ router.put('/app-settings', authenticate, (req, res) => {
|
||||
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
||||
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
|
||||
|
||||
const { allow_registration } = req.body;
|
||||
const { allow_registration, allowed_file_types } = req.body;
|
||||
if (allow_registration !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
|
||||
}
|
||||
if (allowed_file_types !== undefined) {
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -117,6 +117,18 @@ router.get('/', authenticate, (req, res) => {
|
||||
|
||||
// Group assignments by day_id
|
||||
const assignmentsByDayId = {};
|
||||
// Load all participants for all assignments
|
||||
const allAssignmentIds = allAssignments.map(a => a.id)
|
||||
const allParticipants = allAssignmentIds.length > 0
|
||||
? db.prepare(`SELECT ap.assignment_id, ap.user_id, u.username, u.avatar FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE ap.assignment_id IN (${allAssignmentIds.map(() => '?').join(',')})`)
|
||||
.all(...allAssignmentIds)
|
||||
: []
|
||||
const participantsByAssignment = {}
|
||||
for (const p of allParticipants) {
|
||||
if (!participantsByAssignment[p.assignment_id]) participantsByAssignment[p.assignment_id] = []
|
||||
participantsByAssignment[p.assignment_id].push({ user_id: p.user_id, username: p.username, avatar: p.avatar })
|
||||
}
|
||||
|
||||
for (const a of allAssignments) {
|
||||
if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = [];
|
||||
assignmentsByDayId[a.day_id].push({
|
||||
@@ -124,6 +136,7 @@ router.get('/', authenticate, (req, res) => {
|
||||
day_id: a.day_id,
|
||||
order_index: a.order_index,
|
||||
notes: a.notes,
|
||||
participants: participantsByAssignment[a.id] || [],
|
||||
created_at: a.created_at,
|
||||
place: {
|
||||
id: a.place_id,
|
||||
|
||||
@@ -22,25 +22,27 @@ const storage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
|
||||
const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv';
|
||||
const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
|
||||
|
||||
function getAllowedExtensions() {
|
||||
try {
|
||||
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get();
|
||||
return row?.value || DEFAULT_ALLOWED_EXTENSIONS;
|
||||
} catch { return DEFAULT_ALLOWED_EXTENSIONS; }
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const blockedExts = ['.svg', '.html', '.htm', '.xml'];
|
||||
if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
|
||||
if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) {
|
||||
return cb(new Error('File type not allowed'));
|
||||
}
|
||||
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
|
||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
||||
const fileExt = ext.replace('.', '');
|
||||
if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('File type not allowed'));
|
||||
|
||||
Reference in New Issue
Block a user