mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)
* fix(security): equalise login response timing to prevent user enumeration (CWE-208)
Always run bcrypt.compareSync regardless of whether the email exists, using a
module-scope DUMMY_PASSWORD_HASH for unknown/OIDC-only accounts. Also wraps the
login handler in a 350ms minimum-latency pad (matching /forgot-password) as
defence-in-depth against CPU jitter and future code-path drift.
Fixes: CWE-203, CWE-208 — Observable Timing Discrepancy (CVSS 5.3 Medium)
* chore(deps): patch hono/picomatch/ip-address/brace-expansion CVEs, bump to node:24-alpine
Extends server/package.json overrides to pin hono >=4.12.16, picomatch >=4.0.4,
brace-expansion >=2.0.3, ip-address >=10.1.1. Adds matching overrides to client/.
Lockfiles regenerated to resolve: hono 4.12.18, ip-address 10.2.0, picomatch 4.0.4.
Also bumps base image node:22-alpine -> node:24-alpine (reduces base image CVEs)
and adds .github/workflows/security.yml to gate PRs on critical/high CVEs via
Docker Scout.
Addresses: CVE-2026-44456, CVE-2026-44455 (hono), CVE-2026-42338 (ip-address),
CVE-2026-33671, CVE-2026-33672 (picomatch), CVE-2026-33750 (brace-expansion)
* chore: update emails in security.md
* ci(security): use docker/login-action for Scout auth instead of env vars
* chore: regenerate lock files
* chore: correct secret names
* chore: pr perms write
* fix(docker): remove package-lock.json from production image after npm ci
Docker Scout reads package-lock.json as an SBOM source and reports all
lockfile entries including devDependencies (e.g. picomatch via vitest/vite)
even when they are not physically installed. The lockfile has no runtime
purpose after npm ci completes, so delete it to ensure Scout only reports
packages actually present in node_modules.
* fix(docker): remove npm CLI from production image to eliminate bundled CVEs
picomatch@4.0.3, brace-expansion@5.0.4, and ip-address@10.1.0 were all
coming from /usr/local/lib/node_modules/npm — npm's own bundled packages
shipped with node:24-alpine. The production container only needs the node
binary to run the server; npm is unused at runtime.
Removing npm + npx after npm ci drops the package count from 500 to 365
and eliminates all npm-ecosystem CVEs (0H 0M remaining from npm packages).
Only busybox CVE-2025-60876 remains, which has no fix in Alpine 3.23.
* fix(deps): remove client overrides and brace-expansion server override; audit fix
brace-expansion ^2.0.3 in the client forced all installations to v2, breaking
minimatch in CI (test:coverage path via @vitest/coverage-v8 -> test-exclude)
which expects the named-export API of brace-expansion v5. The CVE it targeted
(>=4.0.0,<5.0.5) was only in npm's own bundled packages, already eliminated
by removing npm from the Docker image.
Also removes picomatch and ip-address client overrides for the same reason:
all three CVEs sourced from /usr/local/lib/node_modules/npm/, not app deps.
Drops brace-expansion from server overrides (server uses v2.1.0, outside the
affected range >=4.0.0).
* fix(#981): align public share itinerary order with daily planner (#985)
The public share page rendered daily items in a different order than the
authenticated planner because it used a simplified, divergent merge
algorithm. Five specific bugs:
1. shareService never loaded reservation_day_positions, so per-day
transport positions were lost on the share page (fell back to
day_plan_position ?? 999, pushing transports to the bottom).
2. Multi-day transports (overnight trains/flights) only appeared on their
start day due to date-string filtering instead of day_id span logic.
3. Assignment-linked transports appeared twice (once as place, once as
transport card) because the assignment_id exclusion was missing.
4. Time-based transport insertion was absent; missing positions used 999
instead of a computed fractional position from the place timeline.
5. created_at tiebreaker was missing for assignments and notes with equal
order_index/sort_order, making order non-deterministic on the share page.
Fix: extract the authoritative merge logic (parseTimeToMinutes,
getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems)
from DayPlanSidebar into client/src/utils/dayMerge.ts and use it in both
the planner and SharedTripPage. Enrich the shareService payload with
day_positions from reservation_day_positions and add created_at tiebreakers
to the assignment and day_notes ORDER BY clauses.
* fix(#983): shift owner vacay entries when update_trip moves trip window
updateTrip() now calls shiftOwnerEntriesForTripWindow() which looks up
the owner's own vacay plan (not the active plan) and shifts all entries
in the old date window by the same offset as the trip start date.
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scout:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: trek:scan
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/scout-action@v1
|
||||||
|
with:
|
||||||
|
command: cves
|
||||||
|
image: trek:scan
|
||||||
|
only-severities: critical,high
|
||||||
|
exit-code: true
|
||||||
+7
-4
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build React client
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS client-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app/client
|
||||||
COPY client/package*.json ./
|
COPY client/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -7,7 +7,7 @@ COPY client/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Production server
|
# Stage 2: Production server
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,13 +15,16 @@ WORKDIR /app
|
|||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
npm ci --production && \
|
npm ci --production && \
|
||||||
apk del python3 make g++
|
rm package-lock.json && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
COPY --from=client-builder /app/client/dist ./public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN rm -f package-lock.json && \
|
||||||
|
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
|||||||
If you discover a security vulnerability, please report it responsibly:
|
If you discover a security vulnerability, please report it responsibly:
|
||||||
|
|
||||||
1. **Do not** open a public issue
|
1. **Do not** open a public issue
|
||||||
2. Email: **mauriceboe@icloud.com**
|
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
Generated
+357
-1738
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
|
import {
|
||||||
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||||
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
|
type MergedItem,
|
||||||
|
} from '../../utils/dayMerge'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
@@ -362,26 +367,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
|
|
||||||
// Get span phase: how a reservation relates to a specific day (by id)
|
|
||||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
if (!startDayId || startDayId === endDayId) return 'single'
|
|
||||||
if (dayId === startDayId) return 'start'
|
|
||||||
if (dayId === endDayId) return 'end'
|
|
||||||
return 'middle'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the appropriate display time for a reservation on a specific day
|
|
||||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
|
||||||
const phase = getSpanPhase(r, dayId)
|
|
||||||
if (phase === 'end') return r.reservation_end_time || null
|
|
||||||
if (phase === 'middle') return null
|
|
||||||
return r.reservation_time || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get phase label for multi-day badge
|
// Get phase label for multi-day badge
|
||||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||||
if (phase === 'single') return null
|
if (phase === 'single') return null
|
||||||
@@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return { day_id: startId, end_day_id: targetDayId }
|
return { day_id: startId, end_day_id: targetDayId }
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTransportForDay = (dayId: number) => {
|
const getTransportForDay = (dayId: number) =>
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||||
return reservations.filter(r => {
|
|
||||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
|
||||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
|
||||||
|
|
||||||
const startDayId = r.day_id
|
|
||||||
const endDayId = r.end_day_id ?? startDayId
|
|
||||||
|
|
||||||
if (startDayId == null) return false
|
|
||||||
|
|
||||||
if (endDayId !== startDayId) {
|
|
||||||
const startDay = days.find(d => d.id === startDayId)
|
|
||||||
const endDay = days.find(d => d.id === endDayId)
|
|
||||||
const thisDay = days.find(d => d.id === dayId)
|
|
||||||
if (!startDay || !endDay || !thisDay) return false
|
|
||||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
|
||||||
}
|
|
||||||
return startDayId === dayId
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||||
const getActiveRentalsForDay = (dayId: number) => {
|
const getActiveRentalsForDay = (dayId: number) => {
|
||||||
@@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const getDayAssignments = (dayId) =>
|
const getDayAssignments = (dayId) =>
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
|
|
||||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
|
||||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
|
||||||
if (!time) return null
|
|
||||||
// ISO-Format "2025-03-30T09:00:00"
|
|
||||||
if (time.includes('T')) {
|
|
||||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
|
||||||
return h * 60 + m
|
|
||||||
}
|
|
||||||
// Einfaches "HH:MM" Format
|
|
||||||
const parts = time.split(':').map(Number)
|
|
||||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute initial day_plan_position for a transport based on time
|
// Compute initial day_plan_position for a transport based on time
|
||||||
const computeTransportPosition = (r, da) => {
|
const computeTransportPosition = (r, da) => {
|
||||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||||
@@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||||
const da = getDayAssignments(dayId)
|
_getMergedItems({
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
dayAssignments: getDayAssignments(dayId),
|
||||||
const transport = getTransportForDay(dayId)
|
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
dayTransports: getTransportForDay(dayId),
|
||||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
dayId,
|
||||||
const baseItems = [
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
})
|
||||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
|
|
||||||
// Transports are inserted among places based on time
|
|
||||||
const timedTransports = transport.map(r => ({
|
|
||||||
type: 'transport' as const,
|
|
||||||
data: r,
|
|
||||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
|
||||||
|
|
||||||
if (timedTransports.length === 0) return baseItems
|
|
||||||
if (baseItems.length === 0) {
|
|
||||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert transports among places based on per-day position or time
|
|
||||||
const result = [...baseItems]
|
|
||||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
|
||||||
const timed = timedTransports[ti]
|
|
||||||
const minutes = timed.minutes
|
|
||||||
|
|
||||||
// Use per-day position if explicitly set by user reorder
|
|
||||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
|
||||||
if (perDayPos != null) {
|
|
||||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find insertion position: after the last place with time <= this transport's time
|
|
||||||
let insertAfterKey = -Infinity
|
|
||||||
for (const item of result) {
|
|
||||||
if (item.type === 'place') {
|
|
||||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
|
||||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
} else if (item.type === 'transport') {
|
|
||||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
|
||||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
|
||||||
const sortKey = insertAfterKey === -Infinity
|
|
||||||
? lastKey + 0.5 + ti * 0.01
|
|
||||||
: insertAfterKey + 0.01 + ti * 0.001
|
|
||||||
|
|
||||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { createElement } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
function createMarkerIcon(place: any) {
|
function createMarkerIcon(place: any) {
|
||||||
@@ -184,14 +184,16 @@ export default function SharedTripPage() {
|
|||||||
{sortedDays.map((day: any, di: number) => {
|
{sortedDays.map((day: any, di: number) => {
|
||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||||
|
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = getMergedItems({
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
dayAssignments: da,
|
||||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
dayNotes: notes,
|
||||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
dayTransports: dayTransport,
|
||||||
].sort((a, b) => a.k - b.k)
|
dayId: day.id,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
@@ -212,7 +214,7 @@ export default function SharedTripPage() {
|
|||||||
|
|
||||||
{selectedDay === day.id && merged.length > 0 && (
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{merged.map((item: any, idx: number) => {
|
{merged.map((item: any) => {
|
||||||
if (item.type === 'transport') {
|
if (item.type === 'transport') {
|
||||||
const r = item.data
|
const r = item.data
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
|||||||
accommodation_start_day_id?: number | null
|
accommodation_start_day_id?: number | null
|
||||||
accommodation_end_day_id?: number | null
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
|
day_positions?: Record<number, number> | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
endpoints?: ReservationEndpoint[]
|
endpoints?: ReservationEndpoint[]
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
|
describe('parseTimeToMinutes', () => {
|
||||||
|
it('parses HH:MM string', () => {
|
||||||
|
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ISO datetime string', () => {
|
||||||
|
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseTimeToMinutes(null)).toBeNull()
|
||||||
|
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSpanPhase', () => {
|
||||||
|
it('returns single when start === end', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns start for the departure day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns end for the arrival day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns middle for days in between', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayTimeForDay', () => {
|
||||||
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
it('returns reservation_time on start day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns reservation_end_time on end day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for middle day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTransportForDay', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, day_number: 1 },
|
||||||
|
{ id: 2, day_number: 2 },
|
||||||
|
{ id: 3, day_number: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('excludes non-transport types', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes single-day transport on the correct day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes multi-day transport on all spanned days', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes transport linked to an assignment on that day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMergedItems', () => {
|
||||||
|
it('merges places and notes sorted by sortKey', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||||
|
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||||
|
]
|
||||||
|
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||||
|
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||||
|
expect(result[0].data.id).toBe(1)
|
||||||
|
expect(result[1].data.id).toBe(10)
|
||||||
|
expect(result[2].data.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts transport by time when no per-day position is set', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||||
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('per-day position overrides time-based insertion', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
// Transport at 10:30 would normally go between the two places
|
||||||
|
// but per-day position 1.5 puts it after the second place
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
expect(types).toEqual(['place', 'place', 'transport'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
export interface MergedItem {
|
||||||
|
type: 'place' | 'note' | 'transport'
|
||||||
|
sortKey: number
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||||
|
if (!time) return null
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpanPhase(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null },
|
||||||
|
dayId: number
|
||||||
|
): 'single' | 'start' | 'middle' | 'end' {
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
if (!startDayId || startDayId === endDayId) return 'single'
|
||||||
|
if (dayId === startDayId) return 'start'
|
||||||
|
if (dayId === endDayId) return 'end'
|
||||||
|
return 'middle'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayTimeForDay(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
|
dayId: number
|
||||||
|
): string | null {
|
||||||
|
const phase = getSpanPhase(r, dayId)
|
||||||
|
if (phase === 'end') return r.reservation_end_time || null
|
||||||
|
if (phase === 'middle') return null
|
||||||
|
return r.reservation_time || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
|
export function getTransportForDay(opts: {
|
||||||
|
reservations: any[]
|
||||||
|
dayId: number
|
||||||
|
dayAssignmentIds: number[]
|
||||||
|
days: Array<{ id: number; day_number?: number }>
|
||||||
|
}): any[] {
|
||||||
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
|
const getDayOrder = (id: number): number => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||||
|
}
|
||||||
|
const thisDayOrder = getDayOrder(dayId)
|
||||||
|
|
||||||
|
return reservations.filter(r => {
|
||||||
|
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||||
|
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||||
|
|
||||||
|
const startDayId = r.day_id
|
||||||
|
const endDayId = r.end_day_id ?? startDayId
|
||||||
|
|
||||||
|
if (startDayId == null) return false
|
||||||
|
|
||||||
|
if (endDayId !== startDayId) {
|
||||||
|
const startOrder = getDayOrder(startDayId)
|
||||||
|
const endOrder = getDayOrder(endDayId)
|
||||||
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
|
}
|
||||||
|
return startDayId === dayId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
|
export function getMergedItems(opts: {
|
||||||
|
dayAssignments: any[]
|
||||||
|
dayNotes: any[]
|
||||||
|
dayTransports: any[]
|
||||||
|
dayId: number
|
||||||
|
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||||
|
}): MergedItem[] {
|
||||||
|
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||||
|
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||||
|
|
||||||
|
const baseItems: MergedItem[] = [
|
||||||
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||||
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
const timedTransports = transport.map(r => ({
|
||||||
|
type: 'transport' as const,
|
||||||
|
data: r,
|
||||||
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
|
})).sort((a, b) => a.minutes - b.minutes)
|
||||||
|
|
||||||
|
if (timedTransports.length === 0) return baseItems
|
||||||
|
if (baseItems.length === 0) {
|
||||||
|
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transports among base items based on per-day position or time
|
||||||
|
const result = [...baseItems]
|
||||||
|
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||||
|
const timed = timedTransports[ti]
|
||||||
|
const minutes = timed.minutes
|
||||||
|
|
||||||
|
// Per-day position takes precedence (set by user reorder)
|
||||||
|
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||||
|
if (perDayPos != null) {
|
||||||
|
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||||
|
let insertAfterKey = -Infinity
|
||||||
|
for (const item of result) {
|
||||||
|
if (item.type === 'place') {
|
||||||
|
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||||
|
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
} else if (item.type === 'transport') {
|
||||||
|
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||||
|
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||||
|
const sortKey = insertAfterKey === -Infinity
|
||||||
|
? lastKey + 0.5 + ti * 0.01
|
||||||
|
: insertAfterKey + 0.01 + ti * 0.001
|
||||||
|
|
||||||
|
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
}
|
||||||
Generated
+76
-1105
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -40,8 +40,10 @@
|
|||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"hono": "^4.12.12",
|
"hono": "^4.12.16",
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"ip-address": "^10.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
|||||||
@@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
|||||||
res.status(201).json({ token: result.token, user: result.user });
|
res.status(201).json({ token: result.token, user: result.user });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
router.post('/login', authLimiter, async (req: Request, res: Response) => {
|
||||||
|
const started = Date.now();
|
||||||
const result = loginUser(req.body);
|
const result = loginUser(req.body);
|
||||||
if (result.auditAction) {
|
if (result.auditAction) {
|
||||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||||
}
|
}
|
||||||
|
const elapsed = Date.now() - started;
|
||||||
|
if (elapsed < LOGIN_MIN_LATENCY_MS) {
|
||||||
|
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
|
||||||
|
}
|
||||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||||
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||||
setAuthCookie(res, result.token!, req);
|
setAuthCookie(res, result.token!, req);
|
||||||
@@ -166,9 +171,10 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
|||||||
// Generic OK response — identical regardless of email existence, to
|
// Generic OK response — identical regardless of email existence, to
|
||||||
// prevent enumeration via response body OR status code.
|
// prevent enumeration via response body OR status code.
|
||||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||||
// Minimum time we spend inside the forgot handler so a "no such user"
|
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||||
// path does not complete noticeably faster than a real reset.
|
// user" path does not complete noticeably faster than a real operation.
|
||||||
const FORGOT_MIN_LATENCY_MS = 350;
|
const FORGOT_MIN_LATENCY_MS = 350;
|
||||||
|
const LOGIN_MIN_LATENCY_MS = 350;
|
||||||
|
|
||||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
|||||||
|
|
||||||
authenticator.options = { window: 1 };
|
authenticator.options = { window: 1 };
|
||||||
|
|
||||||
|
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||||
|
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||||
|
// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
|
||||||
|
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
|
||||||
|
|
||||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||||
const MFA_BACKUP_CODE_COUNT = 10;
|
const MFA_BACKUP_CODE_COUNT = 10;
|
||||||
@@ -437,14 +442,24 @@ export function loginUser(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
||||||
|
|
||||||
|
// Always run bcrypt — even for unknown/OIDC-only users — so response time
|
||||||
|
// does not reveal whether the email exists in the database (CWE-203/208).
|
||||||
|
const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH;
|
||||||
|
const validPassword = bcrypt.compareSync(password, hashToCheck);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return {
|
return {
|
||||||
error: 'Invalid email or password', status: 401,
|
error: 'Invalid email or password', status: 401,
|
||||||
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!user.password_hash) {
|
||||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
return {
|
||||||
|
error: 'Invalid email or password', status: 401,
|
||||||
|
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return {
|
return {
|
||||||
error: 'Invalid email or password', status: 401,
|
error: 'Invalid email or password', status: 401,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
JOIN places p ON da.place_id = p.id
|
JOIN places p ON da.place_id = p.id
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
WHERE da.day_id IN (${ph})
|
WHERE da.day_id IN (${ph})
|
||||||
ORDER BY da.order_index ASC
|
ORDER BY da.order_index ASC, da.created_at ASC
|
||||||
`).all(...dayIds);
|
`).all(...dayIds);
|
||||||
|
|
||||||
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
|
||||||
@@ -137,7 +137,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
}
|
}
|
||||||
assignments = byDay;
|
assignments = byDay;
|
||||||
|
|
||||||
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
|
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC, created_at ASC`).all(...dayIds);
|
||||||
const notesByDay: Record<number, any[]> = {};
|
const notesByDay: Record<number, any[]> = {};
|
||||||
for (const n of allNotes as any[]) {
|
for (const n of allNotes as any[]) {
|
||||||
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
|
||||||
@@ -153,8 +153,24 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
|||||||
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
WHERE p.trip_id = ? ORDER BY p.created_at DESC
|
||||||
`).all(tripId);
|
`).all(tripId);
|
||||||
|
|
||||||
// Reservations
|
// Reservations — include per-day positions so the client can render the same order as the planner
|
||||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId) as any[];
|
||||||
|
|
||||||
|
const dayPositions = db.prepare(`
|
||||||
|
SELECT rdp.reservation_id, rdp.day_id, rdp.position
|
||||||
|
FROM reservation_day_positions rdp
|
||||||
|
JOIN reservations r ON rdp.reservation_id = r.id
|
||||||
|
WHERE r.trip_id = ?
|
||||||
|
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
|
||||||
|
|
||||||
|
const posMap = new Map<number, Record<number, number>>();
|
||||||
|
for (const dp of dayPositions) {
|
||||||
|
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
|
||||||
|
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
|
||||||
|
}
|
||||||
|
for (const r of reservations) {
|
||||||
|
r.day_positions = posMap.get(r.id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
// Accommodations
|
// Accommodations
|
||||||
const accommodations = db.prepare(`
|
const accommodations = db.prepare(`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { listBudgetItems } from './budgetService';
|
|||||||
import { listItems as listPackingItems } from './packingService';
|
import { listItems as listPackingItems } from './packingService';
|
||||||
import { listReservations } from './reservationService';
|
import { listReservations } from './reservationService';
|
||||||
import { listNotes as listCollabNotes } from './collabService';
|
import { listNotes as listCollabNotes } from './collabService';
|
||||||
|
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
||||||
|
|
||||||
export const MS_PER_DAY = 86400000;
|
export const MS_PER_DAY = 86400000;
|
||||||
export const MAX_TRIP_DAYS = 365;
|
export const MAX_TRIP_DAYS = 365;
|
||||||
@@ -240,6 +241,9 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
|
|||||||
WHERE id=?
|
WHERE id=?
|
||||||
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
|
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
|
||||||
|
|
||||||
|
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
|
||||||
|
shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
|
||||||
|
|
||||||
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
const dayCount = data.day_count ? Math.min(Math.max(Number(data.day_count) || 7, 1), MAX_TRIP_DAYS) : undefined;
|
||||||
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
||||||
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
||||||
|
|||||||
@@ -101,6 +101,29 @@ export function getActivePlanId(userId: number): number {
|
|||||||
return getActivePlan(userId).id;
|
return getActivePlan(userId).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shiftOwnerEntriesForTripWindow(
|
||||||
|
ownerId: number,
|
||||||
|
oldStart: string,
|
||||||
|
oldEnd: string,
|
||||||
|
newStart: string
|
||||||
|
): void {
|
||||||
|
const row = db.prepare(
|
||||||
|
'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days'
|
||||||
|
).get(newStart, oldStart) as { days: number } | undefined;
|
||||||
|
const offset = row?.days ?? 0;
|
||||||
|
if (offset === 0) return;
|
||||||
|
|
||||||
|
const plan = getOwnPlan(ownerId);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE OR IGNORE vacay_entries
|
||||||
|
SET date = date(date, ? || ' days')
|
||||||
|
WHERE plan_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND date BETWEEN ? AND ?`
|
||||||
|
).run(`${offset >= 0 ? '+' : ''}${offset}`, plan.id, ownerId, oldStart, oldEnd);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPlanUsers(planId: number): VacayUser[] {
|
export function getPlanUsers(planId: number): VacayUser[] {
|
||||||
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
||||||
if (!plan) return [];
|
if (!plan) return [];
|
||||||
|
|||||||
@@ -285,3 +285,61 @@ describe('Shared trip — day assignments and notes', () => {
|
|||||||
expect(res.body.assignments).toEqual({});
|
expect(res.body.assignments).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Shared trip — ordering parity (issue #981)', () => {
|
||||||
|
it('SHARE-014 — assignments with same order_index are ordered by created_at (tiebreaker)', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||||
|
const place1 = createPlace(testDb, trip.id, { name: 'First Created' });
|
||||||
|
const place2 = createPlace(testDb, trip.id, { name: 'Second Created' });
|
||||||
|
|
||||||
|
// Both with order_index = 0 (schema default) but different created_at
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T10:00:00')"
|
||||||
|
).run(day.id, place1.id);
|
||||||
|
testDb.prepare(
|
||||||
|
"INSERT INTO day_assignments (day_id, place_id, order_index, created_at) VALUES (?, ?, 0, '2025-01-01T11:00:00')"
|
||||||
|
).run(day.id, place2.id);
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const assignments = res.body.assignments[day.id];
|
||||||
|
expect(assignments).toHaveLength(2);
|
||||||
|
expect(assignments[0].place.name).toBe('First Created');
|
||||||
|
expect(assignments[1].place.name).toBe('Second Created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARE-015 — reservations include day_positions map from reservation_day_positions table', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const day = createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||||
|
|
||||||
|
const res1 = testDb.prepare(
|
||||||
|
"INSERT INTO reservations (trip_id, title, type, day_id, reservation_time) VALUES (?, ?, ?, ?, ?)"
|
||||||
|
).run(trip.id, 'Test Flight', 'flight', day.id, '2025-09-01T09:00:00');
|
||||||
|
const reservationId = Number(res1.lastInsertRowid);
|
||||||
|
|
||||||
|
// Insert a per-day position
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)'
|
||||||
|
).run(reservationId, day.id, 1.5);
|
||||||
|
|
||||||
|
const { body: { token } } = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/share-link`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ share_bookings: true });
|
||||||
|
|
||||||
|
const shareRes = await request(app).get(`/api/shared/${token}`);
|
||||||
|
expect(shareRes.status).toBe(200);
|
||||||
|
const reservation = shareRes.body.reservations.find((r: any) => r.id === reservationId);
|
||||||
|
expect(reservation).toBeDefined();
|
||||||
|
expect(reservation.day_positions).toBeDefined();
|
||||||
|
expect(reservation.day_positions[day.id]).toBe(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -184,6 +184,88 @@ describe('Tool: update_trip', () => {
|
|||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shifts owner vacay entries when update_trip moves trip window by fixed offset', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { start_date: '2026-08-01', end_date: '2026-08-09' });
|
||||||
|
|
||||||
|
// Materialize active vacay plan for owner and entries in old trip window.
|
||||||
|
const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||||
|
const planId = Number(planRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||||
|
).run(user.id, planId, 2026);
|
||||||
|
for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
|
||||||
|
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'update_trip',
|
||||||
|
arguments: { tripId: trip.id, start_date: '2026-08-08', end_date: '2026-08-16' },
|
||||||
|
});
|
||||||
|
const data = parseToolResult(result) as any;
|
||||||
|
expect(data.trip.start_date).toBe('2026-08-08');
|
||||||
|
expect(data.trip.end_date).toBe('2026-08-16');
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldWindow = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
|
||||||
|
).all(planId, user.id) as { date: string }[];
|
||||||
|
expect(oldWindow).toHaveLength(0);
|
||||||
|
|
||||||
|
const shifted = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
|
||||||
|
).all(planId, user.id) as { date: string }[];
|
||||||
|
expect(shifted.map(r => r.date)).toEqual([
|
||||||
|
'2026-08-10',
|
||||||
|
'2026-08-11',
|
||||||
|
'2026-08-12',
|
||||||
|
'2026-08-13',
|
||||||
|
'2026-08-14',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shifts entries from the owners own plan even if another vacay plan is active', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const { user: otherOwner } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-07' });
|
||||||
|
|
||||||
|
// Own plan with entries that should be shifted.
|
||||||
|
const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
|
||||||
|
const ownPlanId = Number(ownPlanRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
|
||||||
|
testDb.prepare(
|
||||||
|
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
|
||||||
|
).run(user.id, ownPlanId, 2026);
|
||||||
|
for (const d of ['2026-09-02', '2026-09-03']) {
|
||||||
|
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different accepted plan becomes "active" for the owner.
|
||||||
|
const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
|
||||||
|
const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
|
||||||
|
testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
|
||||||
|
|
||||||
|
await withHarness(user.id, async (h) => {
|
||||||
|
const result = await h.client.callTool({
|
||||||
|
name: 'update_trip',
|
||||||
|
arguments: { tripId: trip.id, start_date: '2026-09-08', end_date: '2026-09-14' },
|
||||||
|
});
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldWindow = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
|
||||||
|
).all(ownPlanId, user.id) as { date: string }[];
|
||||||
|
expect(oldWindow).toHaveLength(0);
|
||||||
|
|
||||||
|
const shifted = testDb.prepare(
|
||||||
|
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
|
||||||
|
).all(ownPlanId, user.id) as { date: string }[];
|
||||||
|
expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user