mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 |
@@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.5
|
||||
version: 3.0.8
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.5"
|
||||
appVersion: "3.0.8"
|
||||
|
||||
@@ -22,6 +22,9 @@ data:
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -30,6 +30,8 @@ env:
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
const items = grouped.get(oldName) || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
|
||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
||||
id: 400,
|
||||
title: 'Flight to Rome',
|
||||
type: 'flight',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T14:30:00',
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||
|
||||
@@ -140,23 +140,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const totalCost = Object.values(assignments || {})
|
||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
|
||||
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (!startId || startId === endId) return 'single'
|
||||
if (dayId === startId) return 'start'
|
||||
if (dayId === endId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||
const phase = pdfGetSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId == null) return false
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find(d => d.id === startId)
|
||||
const endDay = sorted.find(d => d.id === endId)
|
||||
const thisDay = sorted.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||
}
|
||||
return startId === dayId
|
||||
})
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Reservations for this day (hotel rendered via accommodations block)
|
||||
const dayReservations = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id)
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
@@ -177,13 +212,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
|
||||
@@ -66,7 +66,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const fmtTime = (v) => {
|
||||
if (!v) return v
|
||||
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
return formatTime12(v, is12h)
|
||||
}
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const collapsed = collapsedProp
|
||||
const toggleCollapse = () => onToggleCollapse?.()
|
||||
|
||||
@@ -1576,7 +1576,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
{res.reservation_end_time && ` – ${(() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
})()}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
|
||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
} else if (form.reservation_end_time && form.reservation_time) {
|
||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
|
||||
@@ -236,7 +236,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
{(() => {
|
||||
const endDatePart = r.reservation_end_time
|
||||
? r.reservation_end_time.includes('T')
|
||||
? r.reservation_end_time.split('T')[0]
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||
? r.reservation_end_time
|
||||
: null
|
||||
: null
|
||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||
})() && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -343,7 +343,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripActions.loadReservations(tripId)
|
||||
if (tripId) {
|
||||
tripActions.loadReservations(tripId)
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
@@ -1106,7 +1109,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([202, 201, 203]);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||
);
|
||||
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([211, 212]);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -223,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
)
|
||||
entries.sort((a, b) => {
|
||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||
const atime = a.entry_time || ''
|
||||
const btime = b.entry_time || ''
|
||||
if (atime !== btime) return atime.localeCompare(btime)
|
||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
return a.id - b.id
|
||||
})
|
||||
return { current: { ...s.current, entries } }
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||
|
||||
@@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
|
||||
|
||||
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
||||
# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
|
||||
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
||||
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
||||
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||
|
||||
Generated
+27
-62
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
@@ -30,7 +30,7 @@
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -663,9 +663,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -682,9 +679,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -701,9 +695,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -720,9 +711,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -739,9 +727,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -758,9 +743,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -777,9 +759,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -796,9 +775,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -815,9 +791,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -840,9 +813,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -865,9 +835,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -890,9 +857,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -915,9 +879,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -940,9 +901,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -965,9 +923,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -990,9 +945,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1551,6 +1503,18 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodable"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
@@ -3767,9 +3731,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
|
||||
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3782,9 +3746,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.12",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.12.tgz",
|
||||
"integrity": "sha512-nUR0q8PPfoA/svPM43Gup7vLOZWppaNrYgGmrVqrAVJa7cOH4hMG6FX9M4mQ8dZA1/ObGZHzES7Ed88hxEBSJg==",
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz",
|
||||
"integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -3793,7 +3757,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"@nodable/entities": "^2.1.0",
|
||||
"fast-xml-builder": "^1.1.5",
|
||||
"path-expression-matcher": "^1.5.0",
|
||||
"strnum": "^2.2.3"
|
||||
},
|
||||
@@ -6481,16 +6446,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||
"integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.8",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -35,7 +35,7 @@
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
}));
|
||||
|
||||
if (shouldForceHttps) {
|
||||
|
||||
@@ -2107,6 +2107,29 @@ function runMigrations(db: Database.Database): void {
|
||||
!= substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
// #846: make sort_order authoritative within a day. Previous ORDER BY put
|
||||
// entry_time before sort_order, silently ignoring reorder clicks when two
|
||||
// same-date entries had different times. Backfill renumbers using the old
|
||||
// effective key (entry_time ASC, id ASC) so existing journeys retain their
|
||||
// current visual order.
|
||||
() => {
|
||||
db.exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY journey_id, entry_date
|
||||
ORDER BY entry_time ASC, id ASC
|
||||
) - 1 AS rn
|
||||
FROM journey_entries
|
||||
)
|
||||
UPDATE journey_entries
|
||||
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
|
||||
`);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
|
||||
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||
);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
if (!journey) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
@@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||
|
||||
// Track next sort_order per date so synced skeletons get unique, sequential positions.
|
||||
const dateMaxOrder = new Map<string, number>();
|
||||
const maxRows = db.prepare(
|
||||
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
|
||||
).all(journeyId) as { entry_date: string; m: number }[];
|
||||
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
|
||||
|
||||
for (const place of places) {
|
||||
if (existingPlaceIds.has(place.id)) continue;
|
||||
existingPlaceIds.add(place.id);
|
||||
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||
dateMaxOrder.set(entryDate, nextOrder);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
@@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
journeyId, tripId, place.id, authorId,
|
||||
place.name, entryDate, entryTime,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
place.day_number || 0, now, now
|
||||
nextOrder, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
|
||||
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||
const entryDate = place.day_date;
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||
).get(link.journey_id, entryDate) as { m: number | null };
|
||||
const nextOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
link.journey_id, tripId, placeId, journey.user_id,
|
||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
now, now
|
||||
nextOrder, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { avatarUrl } from './authService';
|
||||
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
|
||||
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
|
||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||
membersByBag.get(m.bag_id)!.push(m);
|
||||
}
|
||||
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
|
||||
return bags.map(b => ({
|
||||
...b,
|
||||
members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||
@@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
|
||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of userIds) ins.run(bagId, uid);
|
||||
return db.prepare(`
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar
|
||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.bag_id = ?
|
||||
`).all(bagId);
|
||||
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
|
||||
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
|
||||
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||
for (const row of rows as any[]) {
|
||||
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
|
||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
|
||||
}
|
||||
|
||||
return assignees;
|
||||
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
||||
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
const updated = db.prepare(`
|
||||
SELECT pca.user_id, u.username, u.avatar
|
||||
FROM packing_category_assignees pca
|
||||
JOIN users u ON pca.user_id = u.id
|
||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||
`).all(tripId, categoryName);
|
||||
`).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
|
||||
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
removeContributor,
|
||||
getSuggestions,
|
||||
syncTripPlaces,
|
||||
reorderEntries,
|
||||
onPlaceCreated,
|
||||
onPlaceUpdated,
|
||||
onPlaceDeleted,
|
||||
@@ -1465,3 +1466,108 @@ describe('addProviderPhoto — passphrase', () => {
|
||||
expect(row?.passphrase).not.toBe('secret-pp');
|
||||
});
|
||||
});
|
||||
|
||||
// -- reorderEntries (#846) ----------------------------------------------------
|
||||
|
||||
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
|
||||
const now = Date.now();
|
||||
const res = testDb.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
|
||||
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
|
||||
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
|
||||
return { id: Number(res.lastInsertRowid) };
|
||||
}
|
||||
|
||||
describe('reorderEntries', () => {
|
||||
it('JOURNEY-SVC-089: reorder persists and listEntries returns requested order regardless of entry_time', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const e1 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '09:00', sort_order: 0 });
|
||||
const e2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', entry_time: '14:00', sort_order: 1 });
|
||||
|
||||
const ok = reorderEntries(journey.id, user.id, [e2.id, e1.id]);
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const entries = listEntries(journey.id, user.id)!;
|
||||
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
|
||||
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const j1 = createJourney(testDb, user.id);
|
||||
const j2 = createJourney(testDb, user.id);
|
||||
const entry = createJourneyEntry(testDb, j2.id, user.id, { entry_date: '2026-08-02' });
|
||||
|
||||
const ok = reorderEntries(j1.id, user.id, [entry.id]);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it('JOURNEY-SVC-091: reorderEntries does not affect entries on other days', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const day1a = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 0 });
|
||||
const day1b = insertEntry(journey.id, user.id, { entry_date: '2026-08-01', sort_order: 1 });
|
||||
const day2 = insertEntry(journey.id, user.id, { entry_date: '2026-08-02', sort_order: 0 });
|
||||
|
||||
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
|
||||
|
||||
const entries = listEntries(journey.id, user.id)!;
|
||||
const day2Entry = entries.find(e => e.id === day2.id)!;
|
||||
expect(day2Entry.sort_order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncTripPlaces sort_order', () => {
|
||||
it('JOURNEY-SVC-092: assigns unique sequential sort_order per date for same-day places', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Order Trip',
|
||||
start_date: '2026-09-01',
|
||||
end_date: '2026-09-02',
|
||||
});
|
||||
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
|
||||
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
|
||||
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
|
||||
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
|
||||
createDayAssignment(testDb, day.id, p1.id);
|
||||
createDayAssignment(testDb, day.id, p2.id);
|
||||
createDayAssignment(testDb, day.id, p3.id);
|
||||
|
||||
syncTripPlaces(journey.id, trip.id, user.id);
|
||||
|
||||
const rows = testDb.prepare(
|
||||
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
|
||||
).all(journey.id) as { sort_order: number }[];
|
||||
const orders = rows.map(r => r.sort_order);
|
||||
expect(new Set(orders).size).toBe(orders.length);
|
||||
expect(orders).toEqual([0, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onPlaceCreated sort_order', () => {
|
||||
it('JOURNEY-SVC-093: assigns MAX+1 sort_order when entries already exist on the target date', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const journey = createJourney(testDb, user.id);
|
||||
const trip = createTrip(testDb, user.id, {
|
||||
title: 'Append Trip',
|
||||
start_date: '2026-10-01',
|
||||
end_date: '2026-10-02',
|
||||
});
|
||||
addTripToJourney(journey.id, trip.id, user.id);
|
||||
|
||||
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
|
||||
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
|
||||
|
||||
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
|
||||
createDayAssignment(testDb, day.id, place.id);
|
||||
onPlaceCreated(trip.id, place.id);
|
||||
|
||||
const newEntry = testDb.prepare(
|
||||
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
|
||||
).get(journey.id, place.id) as { sort_order: number } | undefined;
|
||||
expect(newEntry).toBeDefined();
|
||||
expect(newEntry!.sort_order).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||
<Config Name="FORCE_HTTPS" Target="FORCE_HTTPS" Default="false" Mode="" Description="Optional. When true: HTTPS redirect, HSTS header, CSP upgrade-insecure-requests, and secure cookies. Only useful behind a TLS-terminating proxy. Requires TRUST_PROXY." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||
<Config Name="HSTS_INCLUDE_SUBDOMAINS" Target="HSTS_INCLUDE_SUBDOMAINS" Default="false" Mode="" Description="When true: adds includeSubDomains to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||
<Config Name="COOKIE_SECURE" Target="COOKIE_SECURE" Default="true" Mode="" Description="Auto-derived (true in production or when FORCE_HTTPS=true). Set to false to force session cookies over plain HTTP. Not recommended for production." Type="Variable" Display="advanced" Required="false" Mask="false">true</Config>
|
||||
<Config Name="TRUST_PROXY" Target="TRUST_PROXY" Default="1" Mode="" Description="Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production; off in development unless set. Required for FORCE_HTTPS." Type="Variable" Display="advanced" Required="false" Mask="false">1</Config>
|
||||
<Config Name="ALLOW_INTERNAL_NETWORK" Target="ALLOW_INTERNAL_NETWORK" Default="false" Mode="" Description="Allow outbound requests to private/RFC-1918 IP addresses. Set to true if Immich or other integrated services are hosted on your local network." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
|
||||
|
||||
@@ -53,6 +53,7 @@ These three variables work together behind a TLS-terminating reverse proxy. See
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `FORCE_HTTPS` | When `true`: 301-redirects HTTP→HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, forces cookie `secure` flag. Only useful behind a TLS proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `TRUST_PROXY` | Number of trusted proxy hops. Tells Express to read the real client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` automatically in production. Required for `FORCE_HTTPS` to detect the forwarded protocol. | `1` (production) |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived as `true` when `NODE_ENV=production` or `FORCE_HTTPS=true`. Set to `false` only as an escape hatch for LAN testing without TLS — not recommended in production. | auto |
|
||||
|
||||
|
||||
@@ -36,18 +36,20 @@ When you have a day selected, a dark dashed line connects consecutive places in
|
||||
|
||||
At zoom level 12 or higher, small pill-shaped labels appear between consecutive places and show the estimated **walking time** and **driving time** for each segment. Below zoom 12 they are hidden to keep the map clean.
|
||||
|
||||
> **Requires:** Settings → Display → **Route calculation** must be ON. When this setting is OFF, TREK never queries the routing service, so no pills are calculated or drawn at any zoom level.
|
||||
|
||||
## Reservation and transport overlay
|
||||
|
||||
Flights, trains, cars, and cruises are drawn as overlays between their endpoint places:
|
||||
Flights, trains, cars, and cruises can be drawn as overlays between their endpoint places. Overlays are **off by default** — activate each reservation individually by clicking the small **Route** icon next to the booking row in the day sidebar. The selection is remembered per trip in your browser. Click the icon again to hide it.
|
||||
|
||||
- **Flights and cruises** — geodesic great-circle arcs
|
||||
- **Trains and cars** — straight lines
|
||||
- **Antimeridian crossings** — arcs that would cross the date line are split into sub-arcs to avoid wrapping across the map
|
||||
- **Endpoint markers** — pill-shaped labels with the transport icon and the endpoint code (e.g. IATA airport code) or location name
|
||||
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights.
|
||||
- **Flight stats** — a floating label on the arc shows departure code → arrival code and, when times are available, the duration and great-circle distance. Stats labels are only rendered for flights and require Settings → Display → **Route calculation** to be ON.
|
||||
- **Confirmed reservations** — solid line; **Pending** — dashed line
|
||||
|
||||
> **Admin:** Whether endpoint labels appear is controlled by the **Show connection labels** setting (`map_booking_labels`).
|
||||
> **Admin:** Whether endpoint text labels appear on the endpoint markers is controlled by the **Booking route labels** setting in Settings → Display (`map_booking_labels`).
|
||||
|
||||
## Location button
|
||||
|
||||
|
||||
Reference in New Issue
Block a user