mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 |
@@ -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
|
||||
FROM node:22-alpine AS client-builder
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
@@ -7,7 +7,7 @@ COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,13 +15,16 @@ WORKDIR /app
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
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 --from=client-builder /app/client/dist ./public
|
||||
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 && \
|
||||
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:
|
||||
|
||||
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
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.17
|
||||
version: 3.0.20
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.17"
|
||||
appVersion: "3.0.20"
|
||||
|
||||
Generated
+366
-1740
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.17",
|
||||
"version": "3.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
|
||||
@@ -23,6 +23,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
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 { useDayNotes } from '../../hooks/useDayNotes'
|
||||
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
|
||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
@@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
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
|
||||
})
|
||||
}
|
||||
const getTransportForDay = (dayId: number) =>
|
||||
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
@@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getDayAssignments = (dayId) =>
|
||||
(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
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
@@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||
const baseItems = [
|
||||
...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)
|
||||
}
|
||||
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||
_getMergedItems({
|
||||
dayAssignments: getDayAssignments(dayId),
|
||||
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||
dayTransports: getTransportForDay(dayId),
|
||||
dayId,
|
||||
getDisplayTime: getDisplayTimeForDay,
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -1027,8 +1028,9 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
try {
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
for (const f of normalized) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
onRefresh()
|
||||
@@ -2265,7 +2267,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
if (!files?.length) return
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
setPendingFiles(prev => [...prev, ...normalized])
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,8 +11,8 @@ import { createElement } from 'react'
|
||||
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 { 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 }
|
||||
|
||||
function createMarkerIcon(place: any) {
|
||||
@@ -184,14 +184,16 @@ export default function SharedTripPage() {
|
||||
{sortedDays.map((day: any, di: number) => {
|
||||
const da = assignments[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 merged = [
|
||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||
].sort((a, b) => a.k - b.k)
|
||||
const merged = getMergedItems({
|
||||
dayAssignments: da,
|
||||
dayNotes: notes,
|
||||
dayTransports: dayTransport,
|
||||
dayId: day.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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') {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
|
||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
day_positions?: Record<number, number> | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
endpoints?: ReservationEndpoint[]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function looksLikeHeic(file: File): boolean {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||
}
|
||||
|
||||
export async function normalizeImageFile(file: File): Promise<File> {
|
||||
if (!looksLikeHeic(file)) return file
|
||||
const { isHeic, heicTo } = await import('heic-to')
|
||||
if (!(await isHeic(file))) return file
|
||||
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||
}
|
||||
@@ -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
+78
-1107
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.17",
|
||||
"version": "3.0.20",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -40,8 +40,10 @@
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.12",
|
||||
"@hono/node-server": "^1.19.13"
|
||||
"hono": "^4.12.16",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/archiver": "^7.0.0",
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
|
||||
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.resource) qs.set('resource', params.resource.href);
|
||||
|
||||
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||
},
|
||||
|
||||
// Not called because skipLocalPkceValidation = true.
|
||||
|
||||
@@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
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);
|
||||
if (result.auditAction) {
|
||||
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.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||
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
|
||||
// prevent enumeration via response body OR status code.
|
||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||
// Minimum time we spend inside the forgot handler so a "no such user"
|
||||
// path does not complete noticeably faster than a real reset.
|
||||
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||
// user" path does not complete noticeably faster than a real operation.
|
||||
const FORGOT_MIN_LATENCY_MS = 350;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
|
||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
|
||||
@@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
|
||||
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 mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
||||
};
|
||||
}
|
||||
|
||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
||||
if (!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) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
|
||||
@@ -316,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = escapeHtml(body);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -396,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
|
||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||
`;
|
||||
return buildEmailHtml(subject, block, lang);
|
||||
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,7 +114,7 @@ export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
JOIN places p ON da.place_id = p.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE da.day_id IN (${ph})
|
||||
ORDER BY da.order_index ASC
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(...dayIds);
|
||||
|
||||
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;
|
||||
|
||||
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[]> = {};
|
||||
for (const n of allNotes as any[]) {
|
||||
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
|
||||
`).all(tripId);
|
||||
|
||||
// Reservations
|
||||
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
|
||||
// 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) 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
|
||||
const accommodations = db.prepare(`
|
||||
|
||||
@@ -7,6 +7,7 @@ import { listBudgetItems } from './budgetService';
|
||||
import { listItems as listPackingItems } from './packingService';
|
||||
import { listReservations } from './reservationService';
|
||||
import { listNotes as listCollabNotes } from './collabService';
|
||||
import { shiftOwnerEntriesForTripWindow } from './vacayService';
|
||||
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const MAX_TRIP_DAYS = 365;
|
||||
@@ -240,6 +241,9 @@ export function updateTrip(tripId: string | number, userId: number, data: Update
|
||||
WHERE id=?
|
||||
`).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;
|
||||
if (newStart !== trip.start_date || newEnd !== trip.end_date || dayCount)
|
||||
generateDays(tripId, newStart || null, newEnd || null, undefined, dayCount);
|
||||
|
||||
@@ -101,6 +101,29 @@ export function getActivePlanId(userId: number): number {
|
||||
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[] {
|
||||
const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined;
|
||||
if (!plan) return [];
|
||||
|
||||
@@ -285,3 +285,61 @@ describe('Shared trip — day assignments and notes', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -37,6 +37,7 @@ Each entry corresponds to a day in your journey. The entry editor provides:
|
||||
|
||||
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
|
||||
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
|
||||
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
|
||||
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
|
||||
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
|
||||
- **Location** — pin the entry to a map location.
|
||||
|
||||
Reference in New Issue
Block a user