diff --git a/client/src/api/client.js b/client/src/api/client.js index c6a78fc..72f1673 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -95,6 +95,8 @@ export const assignmentsApi = { reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data), move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data), update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data), + getParticipants: (tripId, id) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), + setParticipants: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data), } export const packingApi = { diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.jsx index e8a3391..388b2b4 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, Pencil } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -152,6 +152,7 @@ export default function BudgetPanel({ tripId }) { const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore() const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') + const [editingCat, setEditingCat] = useState(null) // { name, value } const currency = trip?.currency || 'EUR' const fmt = (v, cur) => fmtNum(v, locale, cur) @@ -187,6 +188,11 @@ export default function BudgetPanel({ tripId }) { const items = grouped[cat] || [] for (const item of items) await deleteBudgetItem(tripId, item.id) } + const handleRenameCategory = async (oldName, newName) => { + if (!newName.trim() || newName.trim() === oldName) return + const items = grouped[oldName] || [] + for (const item of items) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) + } const handleAddCategory = () => { if (!newCategoryName.trim()) return addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 }) @@ -239,9 +245,27 @@ export default function BudgetPanel({ tripId }) { return (
-
+
- {cat} + {editingCat?.name === cat ? ( + setEditingCat({ ...editingCat, value: e.target.value })} + onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }} + onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }} + style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }} + /> + ) : ( + <> + {cat} + + + )}
{fmt(subtotal, currency)} diff --git a/client/src/components/Files/FileManager.jsx b/client/src/components/Files/FileManager.jsx index 3ddfa45..2c5ef26 100644 --- a/client/src/components/Files/FileManager.jsx +++ b/client/src/components/Files/FileManager.jsx @@ -77,7 +77,7 @@ function SourceBadge({ icon: Icon, label }) { ) } -export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) { +export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }) { const [uploading, setUploading] = useState(false) const [filterType, setFilterType] = useState('all') const [lightboxFile, setLightboxFile] = useState(null) @@ -229,6 +229,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate, <>

{t('files.dropzone')}

{t('files.dropzoneHint')}

+

+ {(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB +

)}
diff --git a/client/src/components/Planner/DayDetailPanel.jsx b/client/src/components/Planner/DayDetailPanel.jsx index 84448bc..8cf215f 100644 --- a/client/src/components/Planner/DayDetailPanel.jsx +++ b/client/src/components/Planner/DayDetailPanel.jsx @@ -45,7 +45,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri const [showHotelPicker, setShowHotelPicker] = useState(false) const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id }) const [hotelCategoryFilter, setHotelCategoryFilter] = useState('') - const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '' }) + const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null }) useEffect(() => { if (!day?.date || !lat || !lng) { setWeather(null); return } @@ -71,10 +71,15 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id]) - const handleSetAccommodation = async (placeId) => { + const handleSelectPlace = (placeId) => { + setHotelForm(f => ({ ...f, place_id: placeId })) + } + + const handleSaveAccommodation = async () => { + if (!hotelForm.place_id) return try { const data = await accommodationsApi.create(tripId, { - place_id: placeId, + place_id: hotelForm.place_id, start_day_id: hotelDayRange.start, end_day_id: hotelDayRange.end, check_in: hotelForm.check_in || null, @@ -84,7 +89,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri setAccommodation(data.accommodation) setAccommodations(prev => [...prev, data.accommodation]) setShowHotelPicker(false) - setHotelForm({ check_in: '', check_out: '', confirmation: '' }) + setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null }) onAccommodationChange?.() } catch {} } @@ -309,7 +314,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
)} - @@ -343,8 +348,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
- {/* Day Range (hidden in edit mode) */} - {showHotelPicker !== 'edit' &&
+ {/* Day Range */} +
{t('day.hotelDayRange')}
@@ -378,7 +383,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri {t('day.allDays')}
-
} +
{/* Check-in / Check-out / Confirmation */}
@@ -397,23 +402,6 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
- {/* Edit mode: save button instead of place list */} - {showHotelPicker === 'edit' ? ( -
- -
- ) : <> - {/* Category Filter */} {categories.length > 0 && (
@@ -440,14 +428,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri return filtered.length === 0 ? (
{t('day.noPlacesForHotel')}
) : filtered.map(p => ( - + +
+
, document.body diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 9f2d48c..881e990 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -752,6 +752,23 @@ export default function DayPlanSidebar({ ) })()} + {assignment.participants?.length > 0 && ( +
+ {assignment.participants.slice(0, 5).map((p, pi) => ( +
0 ? -4 : 0, flexShrink: 0, + overflow: 'hidden', + }}> + {p.avatar ? : p.username?.[0]?.toUpperCase()} +
+ ))} + {assignment.participants.length > 5 && ( + +{assignment.participants.length - 5} + )} +
+ )}
+
{place.name} @@ -205,7 +206,7 @@ export default function PlaceInspector({ padding: '2px 8px', borderRadius: 99, }}> - {category.name} + {category.name} ) })()} @@ -213,7 +214,7 @@ export default function PlaceInspector({ {place.address && (
- {place.address} + {place.address}
)} {place.place_time && ( @@ -223,7 +224,7 @@ export default function PlaceInspector({
)} {place.lat && place.lng && ( -
+
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
)} @@ -241,8 +242,8 @@ export default function PlaceInspector({ {/* Content — scrollable */}
- {/* Info-Chips */} -
+ {/* Info-Chips — hidden on mobile, shown on desktop */} +
{googleDetails?.rating && (() => { const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5) return ( @@ -281,64 +282,71 @@ export default function PlaceInspector({
)} - {/* Reservation for this specific assignment */} + {/* Reservation + Participants — side by side */} {(() => { const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null - if (!res) return null - const confirmed = res.status === 'confirmed' - const accentColor = confirmed ? '#16a34a' : '#d97706' + const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null + const currentParticipants = assignment?.participants || [] + const participantIds = currentParticipants.map(p => p.user_id) + const allJoined = currentParticipants.length === 0 + const showParticipants = selectedAssignmentId && tripMembers.length > 1 + if (!res && !showParticipants) return null return ( -
- {/* Header bar */} -
-
- {confirmed ? t('reservations.confirmed') : t('reservations.pending')} - - {res.title} -
- {/* Details grid */} - {(res.reservation_time || res.confirmation_number || res.location || res.notes) && ( -
-
- {res.reservation_time && ( -
-
{t('reservations.date')}
-
- {new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })} +
+ {/* Reservation */} + {res && (() => { + const confirmed = res.status === 'confirmed' + return ( +
+
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')} + + {res.title} +
+
+ {res.reservation_time && ( +
+
{t('reservations.date')}
+
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
-
- )} - {res.reservation_time && ( -
-
{t('reservations.time')}
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} + )} + {res.reservation_time && ( +
+
{t('reservations.time')}
+
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
-
- )} - {res.confirmation_number && ( -
-
{t('reservations.confirmationCode')}
-
{res.confirmation_number}
-
- )} - {res.location && ( -
-
{t('reservations.locationAddress')}
-
{res.location}
-
- )} + )} + {res.confirmation_number && ( +
+
{t('reservations.confirmationCode')}
+
{res.confirmation_number}
+
+ )} +
+ {res.notes &&
{res.notes}
}
- {res.notes && ( -
{res.notes}
- )} -
+ ) + })()} + + {/* Participants */} + {showParticipants && ( + )}
) })()} - {/* Opening hours */} + {/* Opening hours + Files — side by side on desktop */} +
{openingHours && openingHours.length > 0 && (
@@ -482,3 +491,116 @@ function ActionButton({ onClick, variant, icon, label }) { ) } + +function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) { + const [showAdd, setShowAdd] = React.useState(false) + const [hoveredId, setHoveredId] = React.useState(null) + + // Active participants: if allJoined, show all members; otherwise show only those in participantIds + const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id)) + const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id)) + + const handleRemove = (userId) => { + if (!onSetParticipants) return + let newIds + if (allJoined) { + newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id) + } else { + newIds = participantIds.filter(id => id !== userId) + } + if (newIds.length === tripMembers.length) newIds = [] + onSetParticipants(selectedAssignmentId, selectedDayId, newIds) + } + + const handleAdd = (userId) => { + if (!onSetParticipants) return + const newIds = [...participantIds, userId] + if (newIds.length === tripMembers.length) { + onSetParticipants(selectedAssignmentId, selectedDayId, []) + } else { + onSetParticipants(selectedAssignmentId, selectedDayId, newIds) + } + setShowAdd(false) + } + + return ( +
+
+ {t('inspector.participants')} +
+
+ {activeMembers.map(member => { + const isHovered = hoveredId === member.id + const canRemove = activeMembers.length > 1 + return ( +
setHoveredId(member.id)} + onMouseLeave={() => setHoveredId(null)} + onClick={() => { if (canRemove) handleRemove(member.id) }} + style={{ + display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99, + border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`, + background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)', + fontSize: 10, fontWeight: 500, + color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)', + cursor: canRemove ? 'pointer' : 'default', + transition: 'all 0.15s', + }}> +
+ {member.avatar ? : member.username?.[0]?.toUpperCase()} +
+ {member.username} +
+ ) + })} + + {/* Add button */} + {availableToAdd.length > 0 && ( +
+ + + {showAdd && ( +
+ {availableToAdd.map(member => ( + + ))} +
+ )} +
+ )} +
+
+ ) +} diff --git a/client/src/components/shared/CustomTimePicker.jsx b/client/src/components/shared/CustomTimePicker.jsx index 47bcc4a..1b0bc59 100644 --- a/client/src/components/shared/CustomTimePicker.jsx +++ b/client/src/components/shared/CustomTimePicker.jsx @@ -85,6 +85,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00 const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2)))) const m = Math.min(59, Math.max(0, parseInt(s.slice(2)))) onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0')) + } else if (/^\d{1,2}$/.test(clean)) { + const h = Math.min(23, Math.max(0, parseInt(clean))) + onChange(String(h).padStart(2, '0') + ':00') } } diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index 7e82714..fe795bd 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -281,6 +281,12 @@ const de = { 'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com', 'admin.oidcSaved': 'OIDC-Konfiguration gespeichert', + // File Types + 'admin.fileTypes': 'Erlaubte Dateitypen', + 'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.', + 'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.', + 'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert', + // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', @@ -549,6 +555,7 @@ const de = { 'inspector.website': 'Webseite öffnen', 'inspector.addRes': 'Reservierung', 'inspector.editRes': 'Reservierung bearbeiten', + 'inspector.participants': 'Teilnehmer', // Reservations 'reservations.title': 'Buchungen', @@ -641,6 +648,7 @@ const de = { 'files.uploadError': 'Fehler beim Hochladen', 'files.dropzone': 'Dateien hier ablegen', 'files.dropzoneHint': 'oder klicken zum Auswählen', + 'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.uploading': 'Wird hochgeladen...', 'files.filterAll': 'Alle', 'files.filterPdf': 'PDFs', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index d76c0a3..c6ae619 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -281,6 +281,12 @@ const en = { 'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com', 'admin.oidcSaved': 'OIDC configuration saved', + // File Types + 'admin.fileTypes': 'Allowed File Types', + 'admin.fileTypesHint': 'Configure which file types users can upload.', + 'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.', + 'admin.fileTypesSaved': 'File type settings saved', + // Addons 'admin.tabs.addons': 'Addons', 'admin.addons.title': 'Addons', @@ -549,6 +555,7 @@ const en = { 'inspector.website': 'Open Website', 'inspector.addRes': 'Reservation', 'inspector.editRes': 'Edit Reservation', + 'inspector.participants': 'Participants', // Reservations 'reservations.title': 'Bookings', @@ -641,6 +648,7 @@ const en = { 'files.uploadError': 'Upload failed', 'files.dropzone': 'Drop files here', 'files.dropzoneHint': 'or click to browse', + 'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB', 'files.uploading': 'Uploading...', 'files.filterAll': 'All', 'files.filterPdf': 'PDFs', diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 117d632..ece53d8 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -43,6 +43,10 @@ export default function AdminPage() { // Registration toggle const [allowRegistration, setAllowRegistration] = useState(true) + // File types + const [allowedFileTypes, setAllowedFileTypes] = useState('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv') + const [savingFileTypes, setSavingFileTypes] = useState(false) + // API Keys const [mapsKey, setMapsKey] = useState('') const [weatherKey, setWeatherKey] = useState('') @@ -91,6 +95,7 @@ export default function AdminPage() { try { const config = await authApi.getAppConfig() setAllowRegistration(config.allow_registration) + if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) } catch (err) { // ignore } @@ -493,6 +498,39 @@ export default function AdminPage() {
+ {/* Allowed File Types */} +
+
+

{t('admin.fileTypes')}

+

{t('admin.fileTypesHint')}

+
+
+ setAllowedFileTypes(e.target.value)} + placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +

{t('admin.fileTypesFormat')}

+ +
+
+ {/* API Keys */}
diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx index bc229f8..9acdc73 100644 --- a/client/src/pages/TripPlannerPage.jsx +++ b/client/src/pages/TripPlannerPage.jsx @@ -21,7 +21,7 @@ import { useToast } from '../components/shared/Toast' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import { useTranslation } from '../i18n' import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket' -import { addonsApi, accommodationsApi } from '../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' const MIN_SIDEBAR = 200 const MAX_SIDEBAR = 520 @@ -37,6 +37,8 @@ export default function TripPlannerPage() { const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true }) const [tripAccommodations, setTripAccommodations] = useState([]) + const [allowedFileTypes, setAllowedFileTypes] = useState(null) + const [tripMembers, setTripMembers] = useState([]) const loadAccommodations = useCallback(() => { if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) @@ -48,6 +50,9 @@ export default function TripPlannerPage() { data.addons.forEach(a => { map[a.id] = true }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents }) }).catch(() => {}) + authApi.getAppConfig().then(config => { + if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) + }).catch(() => {}) }, []) const TRIP_TABS = [ @@ -104,6 +109,11 @@ export default function TripPlannerPage() { tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripStore.loadFiles(tripId) loadAccommodations() + tripsApi.getMembers(tripId).then(d => { + // Combine owner + members into one list + const all = [d.owner, ...(d.members || [])].filter(Boolean) + setTripMembers(all) + }).catch(() => {}) } }, [tripId]) @@ -582,6 +592,20 @@ export default function TripPlannerPage() { onRemoveAssignment={handleRemoveAssignment} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} + tripMembers={tripMembers} + onSetParticipants={async (assignmentId, dayId, userIds) => { + try { + const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) + useTripStore.setState(state => ({ + assignments: { + ...state.assignments, + [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => + a.id === assignmentId ? { ...a, participants: data.participants } : a + ), + } + })) + } catch {} + }} /> )} @@ -645,6 +669,7 @@ export default function TripPlannerPage() { places={places} reservations={reservations} tripId={tripId} + allowedFileTypes={allowedFileTypes} />
)} diff --git a/server/src/db/database.js b/server/src/db/database.js index 13e500d..9cf8017 100644 --- a/server/src/db/database.js +++ b/server/src/db/database.js @@ -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) ]; diff --git a/server/src/routes/assignments.js b/server/src/routes/assignments.js index a7e0228..5ebbb67 100644 --- a/server/src/routes/assignments.js +++ b/server/src/routes/assignments.js @@ -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; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 07dce01..9f088cb 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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 }); }); diff --git a/server/src/routes/days.js b/server/src/routes/days.js index f6ca4e0..584d67f 100644 --- a/server/src/routes/days.js +++ b/server/src/routes/days.js @@ -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, diff --git a/server/src/routes/files.js b/server/src/routes/files.js index a639be4..cf4c3fe 100644 --- a/server/src/routes/files.js +++ b/server/src/routes/files.js @@ -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'));