Health endpoint, file types config, budget rename, UI fixes

- Add /api/health endpoint (returns 200 OK without auth)
- Update docker-compose healthcheck to use /api/health
- Admin: configurable allowed file types
- Budget categories can now be renamed (inline edit)
- Place inspector: opening hours + files side by side on desktop
- Address clamped to 2 lines, coordinates hidden on mobile
- Category icon-only on mobile, rating hidden on mobile
- Time validation: "10" becomes "10:00"
- Hotel picker: separate save button, edit opens full popup
- Day header background improved for dark mode
- Notes: 150 char limit with counter, textarea input
- Files grid: full width when no opening hours
- Various responsive fixes
This commit is contained in:
Maurice
2026-03-25 00:14:53 +01:00
parent e3cb5745dd
commit 785e8264cd
14 changed files with 3307 additions and 11 deletions

View File

@@ -194,6 +194,24 @@ export const dayNotesApi = {
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const collabApi = {
// Notes
getNotes: (tripId) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
// Polls
getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId, id, optionIndex) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
closePoll: (tripId, id) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
// Chat
getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),

View File

@@ -118,8 +118,9 @@ export default function AddonManager() {
}
function AddonRow({ addon, onToggle, t }) {
const isComingSoon = addon.id === 'collab'
return (
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}>
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
{/* Icon */}
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
<AddonIcon name={addon.icon} size={20} />
@@ -129,6 +130,11 @@ function AddonRow({ addon, onToggle, t }) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
{isComingSoon && (
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
Coming Soon
</span>
)}
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
color: 'var(--text-muted)',
@@ -141,19 +147,20 @@ function AddonRow({ addon, onToggle, t }) {
{/* Toggle */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={() => onToggle(addon)}
onClick={() => !isComingSoon && onToggle(addon)}
disabled={isComingSoon}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
>
<span
className="inline-block h-4 w-4 transform rounded-full transition-transform"
style={{
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
background: 'var(--bg-card)',
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
}}
/>
</button>

View File

@@ -0,0 +1,615 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Send, Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
/* ───────── relative timestamp ───────── */
function formatRelativeTime(isoString, t) {
const now = Date.now()
const then = new Date(isoString).getTime()
const diff = now - then
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return t('collab.chat.justNow')
if (minutes < 60) return t('collab.chat.minutesAgo', { n: minutes })
if (hours < 24) return t('collab.chat.hoursAgo', { n: hours })
const d = new Date(isoString)
const today = new Date()
const yesterday = new Date()
yesterday.setDate(today.getDate() - 1)
if (
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear()
) {
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `yesterday ${hh}:${mm}`
}
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
return `${monthNames[d.getMonth()]} ${d.getDate()}`
}
/* ─────────────────────────────────────── */
/* Component */
/* ─────────────────────────────────────── */
export default function CollabChat({ tripId, currentUser }) {
const { t } = useTranslation()
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const isAtBottom = useRef(true)
/* ── scroll helpers ── */
const scrollToBottom = useCallback((behavior = 'auto') => {
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior })
})
}, [])
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}, [])
/* ── load messages ── */
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getMessages(tripId).then(data => {
if (cancelled) return
const msgs = Array.isArray(data) ? data : data.messages || []
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more (older messages) ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const oldestId = messages[0]?.id
const data = await collabApi.getMessages(tripId, oldestId)
const older = Array.isArray(data) ? data : data.messages || []
if (older.length === 0) {
setHasMore(false)
} else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight
})
}
} catch {
// silently ignore
} finally {
setLoadingMore(false)
}
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => {
if (prev.some(m => m.id === event.message.id)) return prev
return [...prev, event.message]
})
if (isAtBottom.current) {
setTimeout(() => scrollToBottom('smooth'), 30)
}
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.filter(m => m.id !== event.messageId))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, scrollToBottom])
/* ── auto-resize textarea ── */
const handleTextChange = useCallback((e) => {
setText(e.target.value)
const ta = textareaRef.current
if (ta) {
ta.style.height = 'auto'
ta.style.height = Math.min(ta.scrollHeight, 3 * 20 + 18) + 'px'
}
}, [])
/* ── send ── */
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
try {
const payload = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
setMessages(prev => {
if (prev.some(m => m.id === data.message.id)) return prev
return [...prev, data.message]
})
}
setText('')
setReplyTo(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
scrollToBottom('smooth')
} catch {
// keep text on failure so user can retry
} finally {
setSending(false)
}
}, [text, sending, replyTo, tripId, scrollToBottom])
/* ── keyboard ── */
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}, [handleSend])
/* ── delete ── */
const handleDelete = useCallback(async (msgId) => {
try {
await collabApi.deleteMessage(tripId, msgId)
} catch {
// ignore
}
}, [tripId])
/* ── find a replied-to message ── */
const findMessage = useCallback((id) => messages.find(m => m.id === id), [messages])
/* ── helpers ── */
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
const font = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
/* ───────── render: loading ───────── */
if (loading) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
fontFamily: font,
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
color: 'var(--text-faint)',
userSelect: 'none',
padding: 32,
}}>
<div style={{
width: 24,
height: 24,
border: '2px solid var(--border-faint)',
borderTopColor: 'var(--accent)',
borderRadius: '50%',
animation: 'spin .7s linear infinite',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
}
/* ───────── render: main ───────── */
return (
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
fontFamily: font,
position: 'relative',
overflow: 'hidden',
}}>
{/* ── messages area ── */}
{messages.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
color: 'var(--text-faint)',
userSelect: 'none',
padding: 32,
}}>
<MessageCircle size={36} strokeWidth={1.3} style={{ opacity: 0.5 }} />
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>
{t('collab.chat.empty')}
</span>
<span style={{ fontSize: 12, color: 'var(--text-faint)', opacity: 0.7 }}>
{t('collab.chat.emptyDesc') || ''}
</span>
</div>
) : (
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: '12px 16px 8px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
onScroll={checkAtBottom}
>
{/* load more */}
{hasMore && (
<div style={{
display: 'flex',
justifyContent: 'center',
padding: '4px 0 8px',
}}>
<button
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-faint)',
borderRadius: 99,
padding: '5px 14px',
cursor: 'pointer',
fontFamily: font,
textTransform: 'uppercase',
letterSpacing: 0.3,
}}
onClick={handleLoadMore}
disabled={loadingMore}
>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const repliedMsg = msg.reply_to_id ? findMessage(msg.reply_to_id) : null
const prevMsg = messages[idx - 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const showHeader = !own && isNewGroup
return (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: own ? 'flex-end' : 'flex-start',
marginTop: isNewGroup ? 12 : 0,
}}
>
{/* username + avatar for others */}
{showHeader && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 4,
paddingLeft: 2,
}}>
{msg.user_avatar ? (
<img
src={msg.user_avatar}
alt=""
style={{
width: 20,
height: 20,
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
}}
/>
) : (
<span style={{
width: 20,
height: 20,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
fontWeight: 700,
color: 'var(--text-primary)',
background: 'var(--bg-tertiary)',
flexShrink: 0,
lineHeight: 1,
}}>
{(msg.username || '?')[0].toUpperCase()}
</span>
)}
<span style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-faint)',
lineHeight: 1,
}}>
{msg.username}
</span>
</div>
)}
{/* reply quote */}
{repliedMsg && (
<div style={{
padding: '6px 10px',
borderLeft: '2px solid var(--accent)',
background: 'var(--bg-secondary)',
borderRadius: 6,
fontSize: 11,
lineHeight: 1.35,
marginBottom: 4,
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--text-muted)',
alignSelf: own ? 'flex-end' : 'flex-start',
}}>
<strong style={{ fontWeight: 600 }}>{repliedMsg.username}: </strong>
{(repliedMsg.text || '').slice(0, 80)}
{(repliedMsg.text || '').length > 80 ? '...' : ''}
</div>
)}
{/* bubble with hover actions */}
<div
style={{ position: 'relative', maxWidth: '75%' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div style={{
background: own ? 'var(--accent)' : 'var(--bg-secondary)',
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
borderRadius: own ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.45,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{msg.text}
</div>
{/* action buttons */}
<div style={{
position: 'absolute',
top: -10,
display: 'flex',
gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .12s ease',
...(own ? { right: 4 } : { left: 4 }),
}}>
<button
style={{
width: 22,
height: 22,
borderRadius: '50%',
border: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: 0,
flexShrink: 0,
}}
title="Reply"
onClick={() => setReplyTo(msg)}
>
<Reply size={11} />
</button>
{own && (
<button
style={{
width: 22,
height: 22,
borderRadius: '50%',
border: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: 0,
flexShrink: 0,
}}
title="Delete"
onClick={() => handleDelete(msg.id)}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* timestamp */}
<span style={{
fontSize: 9,
color: 'var(--text-faint)',
marginTop: 2,
paddingLeft: 2,
paddingRight: 2,
lineHeight: 1,
}}>
{formatRelativeTime(msg.created_at, t)}
</span>
</div>
)
})}
</div>
)}
{/* ── composer ── */}
<div style={{
flexShrink: 0,
padding: 12,
borderTop: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
}}>
{/* reply preview */}
{replyTo && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 8,
padding: '6px 10px',
borderRadius: 6,
background: 'var(--bg-secondary)',
borderLeft: '2px solid var(--accent)',
fontSize: 12,
color: 'var(--text-muted)',
lineHeight: 1.3,
}}>
<Reply size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
{(replyTo.text || '').length > 60 ? '...' : ''}
</span>
<span
style={{
marginLeft: 'auto',
cursor: 'pointer',
color: 'var(--text-faint)',
fontSize: 16,
fontWeight: 400,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
onClick={() => setReplyTo(null)}
>
&times;
</span>
</div>
)}
<div style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
}}>
<textarea
ref={textareaRef}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.4,
fontFamily: font,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
outline: 'none',
maxHeight: 3 * 20 + 18,
overflow: 'auto',
transition: 'border-color .15s ease',
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (textareaRef.current) {
textareaRef.current.style.borderColor = 'var(--accent)'
}
}}
onBlur={() => {
if (textareaRef.current) {
textareaRef.current.style.borderColor = 'var(--border-primary)'
}
}}
/>
<button
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: 'none',
background: 'var(--accent)',
color: 'var(--accent-text)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (!text.trim() || sending) ? 'default' : 'pointer',
flexShrink: 0,
opacity: (!text.trim() || sending) ? 0.4 : 1,
transition: 'opacity .15s ease',
}}
onClick={handleSend}
disabled={!text.trim() || sending}
>
<Send size={14} style={{ marginLeft: 1 }} />
</button>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
import React from 'react'
import { useAuthStore } from '../../store/authStore'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
export default function CollabPanel({ tripId }) {
const { user } = useAuthStore()
return (
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '16px' }}>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4" style={{ alignItems: 'start' }}>
{/* Chat — takes 1 column, full height */}
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', height: 500 }}>
<CollabChat tripId={tripId} currentUser={user} />
</div>
{/* Notes — takes 1 column */}
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', maxHeight: 500, display: 'flex', flexDirection: 'column' }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
{/* Polls — takes 1 column */}
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', maxHeight: 500, display: 'flex', flexDirection: 'column' }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -344,8 +344,8 @@ export default function PlaceInspector({
)
})()}
{/* Opening hours + Files — side by side on desktop */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{/* Opening hours + Files — side by side on desktop only if both exist */}
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button

View File

@@ -929,6 +929,44 @@ const de = {
'day.confirmation': 'Bestätigung',
'day.editAccommodation': 'Unterkunft bearbeiten',
'day.reservations': 'Reservierungen',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notizen',
'collab.tabs.polls': 'Umfragen',
'collab.chat.send': 'Senden',
'collab.chat.placeholder': 'Nachricht eingeben...',
'collab.chat.empty': 'Starte die Unterhaltung',
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
'collab.chat.loadMore': 'Ältere Nachrichten laden',
'collab.chat.justNow': 'gerade eben',
'collab.chat.minutesAgo': 'vor {n} Min.',
'collab.chat.hoursAgo': 'vor {n} Std.',
'collab.chat.yesterday': 'gestern',
'collab.notes.title': 'Notizen',
'collab.notes.new': 'Neue Notiz',
'collab.notes.empty': 'Noch keine Notizen',
'collab.notes.emptyHint': 'Halte Ideen und Pläne fest',
'collab.notes.all': 'Alle',
'collab.notes.titlePlaceholder': 'Notiztitel',
'collab.notes.contentPlaceholder': 'Schreibe etwas...',
'collab.notes.categoryPlaceholder': 'Kategorie',
'collab.notes.newCategory': 'Neue Kategorie...',
'collab.polls.title': 'Umfragen',
'collab.polls.new': 'Neue Umfrage',
'collab.polls.empty': 'Noch keine Umfragen',
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
'collab.polls.question': 'Frage',
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
'collab.polls.addOption': '+ Option hinzufügen',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Umfrage erstellen',
'collab.polls.close': 'Schließen',
'collab.polls.closed': 'Geschlossen',
'collab.polls.votes': '{n} Stimmen',
'collab.polls.vote': '{n} Stimme',
'collab.polls.multipleChoice': 'Mehrfachauswahl',
'collab.polls.deadline': 'Frist',
}
export default de

View File

@@ -929,6 +929,44 @@ const en = {
'day.confirmation': 'Confirmation',
'day.editAccommodation': 'Edit accommodation',
'day.reservations': 'Reservations',
// Collab Addon
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Polls',
'collab.chat.send': 'Send',
'collab.chat.placeholder': 'Type a message...',
'collab.chat.empty': 'Start the conversation',
'collab.chat.emptyHint': 'Messages are shared with all trip members',
'collab.chat.loadMore': 'Load older messages',
'collab.chat.justNow': 'just now',
'collab.chat.minutesAgo': '{n}m ago',
'collab.chat.hoursAgo': '{n}h ago',
'collab.chat.yesterday': 'yesterday',
'collab.notes.title': 'Notes',
'collab.notes.new': 'New Note',
'collab.notes.empty': 'No notes yet',
'collab.notes.emptyHint': 'Start capturing ideas and plans',
'collab.notes.all': 'All',
'collab.notes.titlePlaceholder': 'Note title',
'collab.notes.contentPlaceholder': 'Write something...',
'collab.notes.categoryPlaceholder': 'Category',
'collab.notes.newCategory': 'New category...',
'collab.polls.title': 'Polls',
'collab.polls.new': 'New Poll',
'collab.polls.empty': 'No polls yet',
'collab.polls.emptyHint': 'Ask the group and vote together',
'collab.polls.question': 'Question',
'collab.polls.questionPlaceholder': 'What should we do?',
'collab.polls.addOption': '+ Add option',
'collab.polls.optionPlaceholder': 'Option {n}',
'collab.polls.create': 'Create Poll',
'collab.polls.close': 'Close',
'collab.polls.closed': 'Closed',
'collab.polls.votes': '{n} votes',
'collab.polls.vote': '{n} vote',
'collab.polls.multipleChoice': 'Multiple choice',
'collab.polls.deadline': 'Deadline',
}
export default en

View File

@@ -16,6 +16,7 @@ import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
@@ -48,7 +49,7 @@ export default function TripPlannerPage() {
addonsApi.enabled().then(data => {
const map = {}
data.addons.forEach(a => { map[a.id] = true })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
}).catch(() => {})
authApi.getAppConfig().then(config => {
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
@@ -61,6 +62,7 @@ export default function TripPlannerPage() {
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
]
const [activeTab, setActiveTab] = useState('plan')
@@ -673,6 +675,12 @@ export default function TripPlannerPage() {
/>
</div>
)}
{activeTab === 'collab' && (
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
<CollabPanel tripId={tripId} />
</div>
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />

View File

@@ -14,7 +14,7 @@ services:
- ./uploads:/app/uploads
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -316,6 +316,54 @@ function initDb() {
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Collab addon tables
CREATE TABLE IF NOT EXISTS collab_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT DEFAULT 'General',
title TEXT NOT NULL,
content TEXT,
color TEXT DEFAULT '#6366f1',
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_poll_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
);
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
`);
// Create indexes for performance
@@ -457,6 +505,57 @@ function initDb() {
)
`);
},
// 25: Collab addon tables
() => {
_db.exec(`
CREATE TABLE IF NOT EXISTS collab_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT DEFAULT 'General',
title TEXT NOT NULL,
content TEXT,
color TEXT DEFAULT '#6366f1',
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collab_poll_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
);
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
`);
// Ensure collab addon exists for existing installations
try {
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 0, 6)").run();
} catch {}
},
// Future migrations go here (append only, never reorder)
];
@@ -505,6 +604,7 @@ function initDb() {
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', sort_order: 6 },
];
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);

View File

@@ -71,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
const weatherRoutes = require('./routes/weather');
const settingsRoutes = require('./routes/settings');
const budgetRoutes = require('./routes/budget');
const collabRoutes = require('./routes/collab');
const backupRoutes = require('./routes/backup');
const oidcRoutes = require('./routes/oidc');
@@ -83,8 +84,10 @@ app.use('/api/trips/:tripId/places', placesRoutes);
app.use('/api/trips/:tripId/packing', packingRoutes);
app.use('/api/trips/:tripId/files', filesRoutes);
app.use('/api/trips/:tripId/budget', budgetRoutes);
app.use('/api/trips/:tripId/collab', collabRoutes);
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
app.use('/api', assignmentsRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api/categories', categoriesRoutes);

365
server/src/routes/collab.js Normal file
View File

@@ -0,0 +1,365 @@
const express = require('express');
const { db, canAccessTrip } = require('../db/database');
const { authenticate } = require('../middleware/auth');
const { broadcast } = require('../websocket');
const router = express.Router({ mergeParams: true });
function verifyTripAccess(tripId, userId) {
return canAccessTrip(tripId, userId);
}
// ─── NOTES ───────────────────────────────────────────────────────────────────
// GET /notes - list all notes for trip
router.get('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const notes = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.trip_id = ?
ORDER BY n.pinned DESC, n.updated_at DESC
`).all(tripId);
res.json({ notes });
});
// POST /notes - create note
router.post('/notes', authenticate, (req, res) => {
const { tripId } = req.params;
const { title, content, category, color } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!title) return res.status(400).json({ error: 'Title is required' });
const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1');
const note = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ note });
broadcast(tripId, 'collab:note:created', { note }, req.headers['x-socket-id']);
});
// PUT /notes/:id - update note
router.put('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { title, content, category, color, pinned } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
db.prepare(`
UPDATE collab_notes SET
title = COALESCE(?, title),
content = CASE WHEN ? THEN ? ELSE content END,
category = COALESCE(?, category),
color = COALESCE(?, color),
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title || null,
content !== undefined ? 1 : 0, content !== undefined ? content : null,
category || null,
color || null,
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
id
);
const note = db.prepare(`
SELECT n.*, u.username, u.avatar
FROM collab_notes n
JOIN users u ON n.user_id = u.id
WHERE n.id = ?
`).get(id);
res.json({ note });
broadcast(tripId, 'collab:note:updated', { note }, req.headers['x-socket-id']);
});
// DELETE /notes/:id - delete note
router.delete('/notes/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!existing) return res.status(404).json({ error: 'Note not found' });
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
});
// ─── POLLS ───────────────────────────────────────────────────────────────────
// Helper: fetch a poll with aggregated votes
function getPollWithVotes(pollId) {
const poll = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.id = ?
`).get(pollId);
if (!poll) return null;
poll.options = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(pollId);
poll.votes = votes;
return poll;
}
// GET /polls - list all polls with votes
router.get('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const rows = db.prepare(`
SELECT p.*, u.username, u.avatar
FROM collab_polls p
JOIN users u ON p.user_id = u.id
WHERE p.trip_id = ?
ORDER BY p.created_at DESC
`).all(tripId);
const polls = rows.map(poll => {
poll.options = JSON.parse(poll.options);
const votes = db.prepare(`
SELECT v.option_index, v.user_id, u.username, u.avatar
FROM collab_poll_votes v
JOIN users u ON v.user_id = u.id
WHERE v.poll_id = ?
`).all(poll.id);
poll.votes = votes;
return poll;
});
res.json({ polls });
});
// POST /polls - create poll
router.post('/polls', authenticate, (req, res) => {
const { tripId } = req.params;
const { question, options, multiple, deadline } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!question) return res.status(400).json({ error: 'Question is required' });
if (!Array.isArray(options) || options.length < 2) {
return res.status(400).json({ error: 'At least 2 options are required' });
}
const result = db.prepare(`
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
VALUES (?, ?, ?, ?, ?, ?)
`).run(tripId, req.user.id, question, JSON.stringify(options), multiple ? 1 : 0, deadline || null);
const poll = getPollWithVotes(result.lastInsertRowid);
res.status(201).json({ poll });
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
});
// POST /polls/:id/vote - toggle vote on poll
router.post('/polls/:id/vote', authenticate, (req, res) => {
const { tripId, id } = req.params;
const { option_index } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
const options = JSON.parse(poll.options);
if (option_index < 0 || option_index >= options.length) {
return res.status(400).json({ error: 'Invalid option index' });
}
// Toggle: if vote exists, remove it; otherwise add it
const existingVote = db.prepare(
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
).get(id, req.user.id, option_index);
if (existingVote) {
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
} else {
// If not multiple choice, remove any existing votes by this user first
if (!poll.multiple) {
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
}
db.prepare(
'INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)'
).run(id, req.user.id, option_index);
}
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// PUT /polls/:id/close - close poll
router.put('/polls/:id/close', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
const updatedPoll = getPollWithVotes(id);
res.json({ poll: updatedPoll });
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
});
// DELETE /polls/:id - delete poll
router.delete('/polls/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
});
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
// GET /messages - list messages (last 100, with pagination via ?before=id)
router.get('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { before } = req.query;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
let messages;
if (before) {
messages = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ? AND m.id < ?
ORDER BY m.id DESC
LIMIT 100
`).all(tripId, before);
} else {
messages = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.trip_id = ?
ORDER BY m.id DESC
LIMIT 100
`).all(tripId);
}
// Return in chronological order (oldest first)
messages.reverse();
res.json({ messages });
});
// POST /messages - send message
router.post('/messages', authenticate, (req, res) => {
const { tripId } = req.params;
const { text, reply_to } = req.body;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
// Validate reply_to if provided
if (reply_to) {
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
}
const result = db.prepare(`
INSERT INTO collab_messages (trip_id, user_id, text, reply_to)
VALUES (?, ?, ?, ?)
`).run(tripId, req.user.id, text.trim(), reply_to || null);
const message = db.prepare(`
SELECT m.*, u.username, u.avatar,
rm.text AS reply_text, ru.username AS reply_username
FROM collab_messages m
JOIN users u ON m.user_id = u.id
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id
WHERE m.id = ?
`).get(result.lastInsertRowid);
res.status(201).json({ message });
broadcast(tripId, 'collab:message:created', { message }, req.headers['x-socket-id']);
});
// DELETE /messages/:id - delete own message
router.delete('/messages/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
const trip = verifyTripAccess(tripId, req.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!message) return res.status(404).json({ error: 'Message not found' });
if (message.user_id !== req.user.id) {
return res.status(403).json({ error: 'You can only delete your own messages' });
}
db.prepare('DELETE FROM collab_messages WHERE id = ?').run(id);
res.json({ success: true });
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id) }, req.headers['x-socket-id']);
});
module.exports = router;