mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c |
@@ -26,6 +26,9 @@ jobs:
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const pull of pulls) {
|
||||
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
|
||||
if (hasBypass) continue;
|
||||
|
||||
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
|
||||
if (!hasLabel) continue;
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ jobs:
|
||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// bypass-branch-check label skips all enforcement
|
||||
if (labels.includes('bypass-branch-check')) {
|
||||
console.log('bypass-branch-check label present, skipping enforcement.');
|
||||
return;
|
||||
}
|
||||
|
||||
// If the base was fixed, remove the label and let it through
|
||||
if (base !== 'main') {
|
||||
if (labels.includes('wrong-base-branch')) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.10
|
||||
version: 3.0.12
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.10"
|
||||
appVersion: "3.0.12"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
@@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
|
||||
)
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
|
||||
|
||||
function transportIcon(type: string) {
|
||||
if (type === 'train') return Train
|
||||
if (type === 'car') return Car
|
||||
if (type === 'cruise') return Ship
|
||||
return Plane
|
||||
}
|
||||
|
||||
interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
onUpload: (fd: FormData) => Promise<any>
|
||||
@@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||
))}
|
||||
{linkedReservations.map(r => (
|
||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
TRANSPORT_TYPES.has(r.type)
|
||||
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
|
||||
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||
))}
|
||||
{file.note_id && (
|
||||
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
)
|
||||
|
||||
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
|
||||
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
|
||||
|
||||
const reservationBtn = (r: Reservation) => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const bookingsSection = reservations.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{reservations.map(r => {
|
||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||
return (
|
||||
<button key={r.id} onClick={async () => {
|
||||
if (isLinked) {
|
||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||
if (file.reservation_id === r.id) {
|
||||
await handleAssign(file.id, { reservation_id: null })
|
||||
} else {
|
||||
try {
|
||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
// Link: if no primary, set it; otherwise use file_links
|
||||
if (!file.reservation_id) {
|
||||
await handleAssign(file.id, { reservation_id: r.id })
|
||||
} else {
|
||||
try {
|
||||
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||
refreshFiles()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{bookingReservations.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{t('files.assignBooking')}
|
||||
</div>
|
||||
{bookingReservations.map(reservationBtn)}
|
||||
</>
|
||||
)}
|
||||
{transportReservations.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
|
||||
{t('files.assignTransport')}
|
||||
</div>
|
||||
{transportReservations.map(reservationBtn)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
}).join('')
|
||||
|
||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
).sort((a, b) => {
|
||||
const startA = days.find(d => d.id === a.start_day_id)
|
||||
const startB = days.find(d => d.id === b.start_day_id)
|
||||
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
|
||||
})
|
||||
|
||||
const accommodationDetails = accommodationsForDay.map(item => {
|
||||
const isCheckIn = day.id === item.start_day_id
|
||||
|
||||
@@ -892,6 +892,277 @@ describe('DayDetailPanel', () => {
|
||||
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
|
||||
|
||||
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
|
||||
function buildNonMonotonicDays() {
|
||||
return [
|
||||
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
|
||||
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
|
||||
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
|
||||
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
|
||||
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
|
||||
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
|
||||
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
|
||||
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
|
||||
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
|
||||
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
|
||||
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
|
||||
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
|
||||
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
|
||||
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
|
||||
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
|
||||
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
|
||||
];
|
||||
}
|
||||
|
||||
// Returns the two CustomSelect trigger buttons for start/end day pickers.
|
||||
// When no dropdown is open, these are the only globally-visible buttons whose textContent
|
||||
// matches /Day \d+/ (the main panel title is a div, not a button).
|
||||
// [0] = start trigger, [1] = end trigger (DOM source order).
|
||||
function getDayPickerTriggers() {
|
||||
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
|
||||
}
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 50, name: 'Range Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
|
||||
|
||||
// Both triggers show "Day 1"; the second one is the end picker.
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
|
||||
expect(capturedBody?.start_day_id).toBe(17);
|
||||
expect(capturedBody?.end_day_id).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 51, name: 'Span Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
|
||||
|
||||
// Set end to day 16 (id=7, low ID but last day by position).
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
|
||||
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
|
||||
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
|
||||
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
|
||||
await userEvent.click(getDayPickerTriggers()[0]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.start_day_id).toBe(25); // day 9
|
||||
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
|
||||
|
||||
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
|
||||
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
|
||||
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// days[0].id=17 (first by position), days[15].id=7 (last by position)
|
||||
expect(capturedBody?.start_day_id).toBe(17);
|
||||
expect(capturedBody?.end_day_id).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
|
||||
const seqDays = [
|
||||
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
|
||||
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
|
||||
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
|
||||
];
|
||||
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
|
||||
let capturedBody: any;
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
capturedBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
accommodation: {
|
||||
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
|
||||
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
|
||||
|
||||
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.start_day_id).toBe(101);
|
||||
expect(capturedBody?.end_day_id).toBe(103);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
let getCallCount = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () => {
|
||||
getCallCount++;
|
||||
const acc = getCallCount === 1
|
||||
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
|
||||
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
|
||||
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
|
||||
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
|
||||
return HttpResponse.json({ accommodations: [acc] });
|
||||
}),
|
||||
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
|
||||
const body = await request.json() as any;
|
||||
return HttpResponse.json({
|
||||
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
|
||||
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||
await screen.findByText('Span Hotel');
|
||||
|
||||
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
|
||||
const allButtons = screen.getAllByRole('button');
|
||||
await userEvent.click(allButtons[2]);
|
||||
|
||||
// Extend end picker to Day 16 (id=7)
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
const place = buildPlace({ id: 55, name: 'Created Hotel' });
|
||||
// Current day: days[5] = id 22, position 5 (within any full-span range)
|
||||
const currentDay = days[5];
|
||||
server.use(
|
||||
http.post('/api/trips/1/accommodations', async ({ request }) => {
|
||||
const body = await request.json() as any;
|
||||
return HttpResponse.json({
|
||||
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
|
||||
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
|
||||
check_in: null, check_out: null, confirmation: null },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
|
||||
await userEvent.click(await screen.findByText(/Add accommodation/i));
|
||||
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
|
||||
|
||||
// Extend end to Day 16 (id=7) — start stays at current day id=22
|
||||
await userEvent.click(getDayPickerTriggers()[1]);
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
|
||||
|
||||
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
|
||||
const days = buildNonMonotonicDays();
|
||||
server.use(
|
||||
http.get('/api/trips/1/accommodations', () =>
|
||||
HttpResponse.json({
|
||||
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
|
||||
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
|
||||
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
|
||||
await screen.findByText('Full Trip Hotel');
|
||||
|
||||
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
|
||||
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
|
||||
await screen.findByText('Full Trip Hotel');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
|
||||
|
||||
@@ -12,6 +12,7 @@ import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
@@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
.then(data => {
|
||||
setAccommodations(data.accommodations || [])
|
||||
const allForDay = (data.accommodations || []).filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
)
|
||||
setDayAccommodations(allForDay)
|
||||
setAccommodation(allForDay[0] || null)
|
||||
@@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
setAccommodations(updated)
|
||||
setAccommodation(newAcc)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
))
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
|
||||
@@ -154,7 +155,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const updated = accommodations.filter(a => a.id !== accommodation.id)
|
||||
setAccommodations(updated)
|
||||
setDayAccommodations(updated.filter(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
))
|
||||
setAccommodation(null)
|
||||
onAccommodationChange?.()
|
||||
@@ -463,7 +464,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.start}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||
@@ -478,7 +479,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.end}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||
@@ -598,9 +599,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const all = d.accommodations || []
|
||||
setAccommodations(all)
|
||||
setDayAccommodations(all.filter(a =>
|
||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
||||
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||
))
|
||||
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
@@ -397,7 +398,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
@@ -1214,7 +1215,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</Tooltip>
|
||||
)}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
.sort((a, b) => {
|
||||
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
|
||||
@@ -1725,7 +1726,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
||||
onClick={() => {
|
||||
if (!canEditDays) return
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||
else onEditReservation?.(res)
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import {
|
||||
buildUser,
|
||||
buildTrip,
|
||||
buildDay,
|
||||
buildReservation,
|
||||
buildTripFile,
|
||||
} from '../../../tests/helpers/factories';
|
||||
import { TransportModal } from './TransportModal';
|
||||
|
||||
vi.mock('react-router-dom', async (importActual) => {
|
||||
const actual = await importActual<typeof import('react-router-dom')>();
|
||||
return { ...actual, useParams: () => ({ id: '1' }) };
|
||||
});
|
||||
|
||||
vi.mock('../shared/CustomTimePicker', () => ({
|
||||
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./AirportSelect', () => ({
|
||||
default: ({ onChange }: { onChange: (a: any) => void }) => (
|
||||
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./LocationSelect', () => ({
|
||||
default: ({ onChange }: { onChange: (l: any) => void }) => (
|
||||
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
|
||||
),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn().mockResolvedValue(undefined),
|
||||
reservation: null,
|
||||
days: [],
|
||||
selectedDayId: null,
|
||||
files: [],
|
||||
onFileUpload: vi.fn().mockResolvedValue(undefined),
|
||||
onFileDelete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('TransportModal', () => {
|
||||
// ── Rendering ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
|
||||
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
|
||||
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
|
||||
const res = buildReservation({ title: 'My Train', type: 'train' });
|
||||
render(<TransportModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<TransportModal {...defaultProps} onClose={onClose} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
|
||||
});
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||
);
|
||||
});
|
||||
|
||||
// ── File attachment ───────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
|
||||
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
|
||||
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
|
||||
const res = buildReservation({ id: 5, type: 'flight' });
|
||||
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
|
||||
(file as any).reservation_id = 5;
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
|
||||
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
|
||||
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||
expect(fd.get('file')).toBeTruthy();
|
||||
expect(fd.get('reservation_id')).toBe('10');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
|
||||
const res = buildReservation({ id: 5, type: 'flight' });
|
||||
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
|
||||
const res = buildReservation({ id: 5, type: 'flight' });
|
||||
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
);
|
||||
|
||||
const res = buildReservation({ id: 5, type: 'flight' });
|
||||
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||
await userEvent.click(screen.getByText('invoice.pdf'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
|
||||
render(<TransportModal {...defaultProps} reservation={null} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
|
||||
|
||||
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
|
||||
const removeBtn = pendingFileRow.querySelector('button')!;
|
||||
await userEvent.click(removeBtn);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
|
||||
await userEvent.click(attachBtn);
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
clickSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
|
||||
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
);
|
||||
|
||||
const res = buildReservation({ id: 7, type: 'car' });
|
||||
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
|
||||
|
||||
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
||||
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('rental-agreement.pdf'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
|
||||
);
|
||||
|
||||
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
|
||||
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
|
||||
await userEvent.click(unlinkBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
|
||||
const savedReservation = buildReservation({ id: 99, type: 'flight' });
|
||||
const onSave = vi.fn().mockResolvedValue(savedReservation);
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
|
||||
fireEvent.change(fileInput, { target: { files: [testFile] } });
|
||||
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
|
||||
const [fd] = onFileUpload.mock.calls[0] as [FormData];
|
||||
expect(fd.get('reservation_id')).toBe('99');
|
||||
expect(fd.get('file')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -89,26 +92,36 @@ const defaultForm = {
|
||||
interface TransportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, any>) => Promise<void>
|
||||
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
await onSave(payload)
|
||||
const saved = await onSave(payload)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('reservation_id', String(saved.id))
|
||||
fd.append('description', form.title)
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
@@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
if (reservation?.id) {
|
||||
setUploadingFile(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('reservation_id', String(reservation.id))
|
||||
fd.append('description', reservation.title)
|
||||
await onFileUpload!(fd)
|
||||
toast.success(t('reservations.toast.fileUploaded'))
|
||||
} catch {
|
||||
toast.error(t('reservations.toast.uploadError'))
|
||||
} finally {
|
||||
setUploadingFile(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
} else {
|
||||
setPendingFiles(prev => [...prev, file])
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const attachedFiles = reservation?.id
|
||||
? files.filter(f =>
|
||||
f.reservation_id === reservation.id ||
|
||||
linkedFileIds.includes(f.id) ||
|
||||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||
)
|
||||
: []
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||
@@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={async () => {
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||
} catch {}
|
||||
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||
if (tripId) loadFiles(tripId)
|
||||
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||
</button>
|
||||
{showFilePicker && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
|
||||
}}>
|
||||
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||
<button key={f.id} type="button" onClick={async () => {
|
||||
try {
|
||||
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||
setLinkedFileIds(prev => [...prev, f.id])
|
||||
setShowFilePicker(false)
|
||||
if (tripId) loadFiles(tripId)
|
||||
} catch {}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
|
||||
@@ -1249,6 +1249,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'فشل حذف الملف',
|
||||
'files.sourcePlan': 'خطة اليوم',
|
||||
'files.sourceBooking': 'الحجز',
|
||||
'files.sourceTransport': 'النقل',
|
||||
'files.attach': 'إرفاق',
|
||||
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
|
||||
'files.trash': 'سلة المهملات',
|
||||
@@ -1261,6 +1262,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'إسناد ملف',
|
||||
'files.assignPlace': 'المكان',
|
||||
'files.assignBooking': 'الحجز',
|
||||
'files.assignTransport': 'النقل',
|
||||
'files.unassigned': 'غير مسند',
|
||||
'files.unlink': 'إزالة الرابط',
|
||||
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
|
||||
|
||||
@@ -1218,6 +1218,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Falha ao excluir arquivo',
|
||||
'files.sourcePlan': 'Plano do dia',
|
||||
'files.sourceBooking': 'Reserva',
|
||||
'files.sourceTransport': 'Transporte',
|
||||
'files.attach': 'Anexar',
|
||||
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
|
||||
'files.trash': 'Lixeira',
|
||||
@@ -1230,6 +1231,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Atribuir arquivo',
|
||||
'files.assignPlace': 'Lugar',
|
||||
'files.assignBooking': 'Reserva',
|
||||
'files.assignTransport': 'Transporte',
|
||||
'files.unassigned': 'Não atribuído',
|
||||
'files.unlink': 'Remover vínculo',
|
||||
'files.toast.trashed': 'Movido para a lixeira',
|
||||
|
||||
@@ -1247,6 +1247,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Nepodařilo se smazat soubor',
|
||||
'files.sourcePlan': 'Denní plán',
|
||||
'files.sourceBooking': 'Rezervace',
|
||||
'files.sourceTransport': 'Doprava',
|
||||
'files.attach': 'Přiložit',
|
||||
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
|
||||
'files.trash': 'Koš',
|
||||
@@ -1259,6 +1260,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Přiřadit soubor',
|
||||
'files.assignPlace': 'Místo',
|
||||
'files.assignBooking': 'Rezervace',
|
||||
'files.assignTransport': 'Doprava',
|
||||
'files.unassigned': 'Nepřiřazeno',
|
||||
'files.unlink': 'Zrušit propojení',
|
||||
'files.toast.trashed': 'Přesunuto do koše',
|
||||
|
||||
@@ -1251,6 +1251,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
||||
'files.sourcePlan': 'Tagesplan',
|
||||
'files.sourceBooking': 'Buchung',
|
||||
'files.sourceTransport': 'Transport',
|
||||
'files.attach': 'Anhängen',
|
||||
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
|
||||
'files.trash': 'Papierkorb',
|
||||
@@ -1263,6 +1264,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Datei zuweisen',
|
||||
'files.assignPlace': 'Ort',
|
||||
'files.assignBooking': 'Buchung',
|
||||
'files.assignTransport': 'Transport',
|
||||
'files.unassigned': 'Nicht zugewiesen',
|
||||
'files.unlink': 'Verknüpfung entfernen',
|
||||
'files.toast.trashed': 'In den Papierkorb verschoben',
|
||||
|
||||
@@ -1322,6 +1322,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Failed to delete file',
|
||||
'files.sourcePlan': 'Day Plan',
|
||||
'files.sourceBooking': 'Booking',
|
||||
'files.sourceTransport': 'Transport',
|
||||
'files.attach': 'Attach',
|
||||
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
||||
'files.trash': 'Trash',
|
||||
@@ -1334,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Assign File',
|
||||
'files.assignPlace': 'Place',
|
||||
'files.assignBooking': 'Booking',
|
||||
'files.assignTransport': 'Transport',
|
||||
'files.unassigned': 'Unassigned',
|
||||
'files.unlink': 'Remove link',
|
||||
'files.toast.trashed': 'Moved to trash',
|
||||
|
||||
@@ -1195,6 +1195,7 @@ const es: Record<string, string> = {
|
||||
'files.toast.deleteError': 'No se pudo eliminar el archivo',
|
||||
'files.sourcePlan': 'Plan diario',
|
||||
'files.sourceBooking': 'Reserva',
|
||||
'files.sourceTransport': 'Transporte',
|
||||
'files.attach': 'Adjuntar',
|
||||
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
|
||||
|
||||
@@ -1682,6 +1683,7 @@ const es: Record<string, string> = {
|
||||
'files.assignTitle': 'Asignar archivo',
|
||||
'files.assignPlace': 'Lugar',
|
||||
'files.assignBooking': 'Reserva',
|
||||
'files.assignTransport': 'Transporte',
|
||||
'files.unassigned': 'Sin asignar',
|
||||
'files.unlink': 'Eliminar vínculo',
|
||||
'files.noteLabel': 'Nota',
|
||||
|
||||
@@ -1245,6 +1245,7 @@ const fr: Record<string, string> = {
|
||||
'files.toast.deleteError': 'Impossible de supprimer le fichier',
|
||||
'files.sourcePlan': 'Plan du jour',
|
||||
'files.sourceBooking': 'Réservation',
|
||||
'files.sourceTransport': 'Transport',
|
||||
'files.attach': 'Joindre',
|
||||
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
|
||||
'files.trash': 'Corbeille',
|
||||
@@ -1257,6 +1258,7 @@ const fr: Record<string, string> = {
|
||||
'files.assignTitle': 'Assigner le fichier',
|
||||
'files.assignPlace': 'Lieu',
|
||||
'files.assignBooking': 'Réservation',
|
||||
'files.assignTransport': 'Transport',
|
||||
'files.unassigned': 'Non attribué',
|
||||
'files.unlink': 'Supprimer le lien',
|
||||
'files.toast.trashed': 'Déplacé dans la corbeille',
|
||||
|
||||
@@ -1246,6 +1246,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
|
||||
'files.sourcePlan': 'Napi terv',
|
||||
'files.sourceBooking': 'Foglalás',
|
||||
'files.sourceTransport': 'Közlekedés',
|
||||
'files.attach': 'Csatolás',
|
||||
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
|
||||
'files.trash': 'Kuka',
|
||||
@@ -1258,6 +1259,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Fájl hozzárendelése',
|
||||
'files.assignPlace': 'Hely',
|
||||
'files.assignBooking': 'Foglalás',
|
||||
'files.assignTransport': 'Közlekedés',
|
||||
'files.unassigned': 'Nincs hozzárendelve',
|
||||
'files.unlink': 'Kapcsolat eltávolítása',
|
||||
'files.toast.trashed': 'Kukába helyezve',
|
||||
|
||||
@@ -1306,6 +1306,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Gagal menghapus file',
|
||||
'files.sourcePlan': 'Rencana Harian',
|
||||
'files.sourceBooking': 'Pemesanan',
|
||||
'files.sourceTransport': 'Transportasi',
|
||||
'files.attach': 'Lampirkan',
|
||||
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
|
||||
'files.trash': 'Sampah',
|
||||
@@ -1318,6 +1319,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Tugaskan File',
|
||||
'files.assignPlace': 'Tempat',
|
||||
'files.assignBooking': 'Pemesanan',
|
||||
'files.assignTransport': 'Transportasi',
|
||||
'files.unassigned': 'Tidak ditugaskan',
|
||||
'files.unlink': 'Hapus tautan',
|
||||
'files.toast.trashed': 'Dipindahkan ke sampah',
|
||||
|
||||
@@ -1246,6 +1246,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Impossibile eliminare il file',
|
||||
'files.sourcePlan': 'Programma giornaliero',
|
||||
'files.sourceBooking': 'Prenotazione',
|
||||
'files.sourceTransport': 'Trasporto',
|
||||
'files.attach': 'Allega',
|
||||
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
|
||||
'files.trash': 'Cestino',
|
||||
@@ -1258,6 +1259,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Assegna file',
|
||||
'files.assignPlace': 'Luogo',
|
||||
'files.assignBooking': 'Prenotazione',
|
||||
'files.assignTransport': 'Trasporto',
|
||||
'files.unassigned': 'Non assegnato',
|
||||
'files.unlink': 'Rimuovi collegamento',
|
||||
'files.toast.trashed': 'Spostato nel cestino',
|
||||
|
||||
@@ -1245,6 +1245,7 @@ const nl: Record<string, string> = {
|
||||
'files.toast.deleteError': 'Bestand verwijderen mislukt',
|
||||
'files.sourcePlan': 'Dagplan',
|
||||
'files.sourceBooking': 'Boeking',
|
||||
'files.sourceTransport': 'Transport',
|
||||
'files.attach': 'Bijvoegen',
|
||||
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
|
||||
'files.trash': 'Prullenbak',
|
||||
@@ -1257,6 +1258,7 @@ const nl: Record<string, string> = {
|
||||
'files.assignTitle': 'Bestand toewijzen',
|
||||
'files.assignPlace': 'Plaats',
|
||||
'files.assignBooking': 'Boeking',
|
||||
'files.assignTransport': 'Transport',
|
||||
'files.unassigned': 'Niet toegewezen',
|
||||
'files.unlink': 'Koppeling verwijderen',
|
||||
'files.toast.trashed': 'Naar prullenbak verplaatst',
|
||||
|
||||
@@ -1197,6 +1197,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.toast.deleteError': 'Nie udało się usunąć pliku',
|
||||
'files.sourcePlan': 'Plan dni',
|
||||
'files.sourceBooking': 'Rezerwacje',
|
||||
'files.sourceTransport': 'Transport',
|
||||
'files.attach': 'Załącz',
|
||||
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
|
||||
'files.trash': 'Kosz',
|
||||
@@ -1209,6 +1210,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'files.assignTitle': 'Przypisz plik',
|
||||
'files.assignPlace': 'Miejsce',
|
||||
'files.assignBooking': 'Rezerwacja',
|
||||
'files.assignTransport': 'Transport',
|
||||
'files.unassigned': 'Nieprzypisane',
|
||||
'files.unlink': 'Usuń link',
|
||||
'files.toast.trashed': 'Przeniesiono do kosza',
|
||||
|
||||
@@ -1245,6 +1245,7 @@ const ru: Record<string, string> = {
|
||||
'files.toast.deleteError': 'Не удалось удалить файл',
|
||||
'files.sourcePlan': 'План дня',
|
||||
'files.sourceBooking': 'Бронирование',
|
||||
'files.sourceTransport': 'Транспорт',
|
||||
'files.attach': 'Прикрепить',
|
||||
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
|
||||
'files.trash': 'Корзина',
|
||||
@@ -1257,6 +1258,7 @@ const ru: Record<string, string> = {
|
||||
'files.assignTitle': 'Назначить файл',
|
||||
'files.assignPlace': 'Место',
|
||||
'files.assignBooking': 'Бронирование',
|
||||
'files.assignTransport': 'Транспорт',
|
||||
'files.unassigned': 'Не назначен',
|
||||
'files.unlink': 'Удалить связь',
|
||||
'files.toast.trashed': 'Перемещено в корзину',
|
||||
|
||||
@@ -1245,6 +1245,7 @@ const zh: Record<string, string> = {
|
||||
'files.toast.deleteError': '删除文件失败',
|
||||
'files.sourcePlan': '日程计划',
|
||||
'files.sourceBooking': '预订',
|
||||
'files.sourceTransport': '交通',
|
||||
'files.attach': '附加',
|
||||
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
|
||||
'files.trash': '回收站',
|
||||
@@ -1257,6 +1258,7 @@ const zh: Record<string, string> = {
|
||||
'files.assignTitle': '分配文件',
|
||||
'files.assignPlace': '地点',
|
||||
'files.assignBooking': '预订',
|
||||
'files.assignTransport': '交通',
|
||||
'files.unassigned': '未分配',
|
||||
'files.unlink': '移除关联',
|
||||
'files.toast.trashed': '已移至回收站',
|
||||
|
||||
@@ -1305,6 +1305,7 @@ const zhTw: Record<string, string> = {
|
||||
'files.toast.deleteError': '刪除檔案失敗',
|
||||
'files.sourcePlan': '日程計劃',
|
||||
'files.sourceBooking': '預訂',
|
||||
'files.sourceTransport': '交通',
|
||||
'files.attach': '附加',
|
||||
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
|
||||
'files.trash': '回收站',
|
||||
@@ -1317,6 +1318,7 @@ const zhTw: Record<string, string> = {
|
||||
'files.assignTitle': '分配檔案',
|
||||
'files.assignPlace': '地點',
|
||||
'files.assignBooking': '預訂',
|
||||
'files.assignTransport': '交通',
|
||||
'files.unassigned': '未分配',
|
||||
'files.unlink': '移除關聯',
|
||||
'files.toast.trashed': '已移至回收站',
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../tests/helpers/render';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { resetAllStores } from '../../tests/helpers/store';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
describe('LoginPage — OIDC redirect preservation', () => {
|
||||
let savedLocation: Location;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
mockNavigate.mockClear();
|
||||
sessionStorage.clear();
|
||||
savedLocation = window.location;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: savedLocation,
|
||||
});
|
||||
});
|
||||
|
||||
function setSearch(search: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...window.location, search },
|
||||
});
|
||||
}
|
||||
|
||||
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not write to sessionStorage when no redirect param is present', async () => {
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/oidc/exchange', () =>
|
||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
||||
setSearch('?oidc_code=testcode123');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
'/oauth/authorize?client_id=foo&state=xyz',
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
|
||||
setSearch('?oidc_code=testcode123');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
||||
setSearch('?oidc_error=token_failed');
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,12 @@ export default function LoginPage(): React.ReactElement {
|
||||
return '/dashboard'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectTarget !== '/dashboard') {
|
||||
sessionStorage.setItem('oidc_redirect', redirectTarget)
|
||||
}
|
||||
}, [redirectTarget])
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -83,7 +89,9 @@ export default function LoginPage(): React.ReactElement {
|
||||
window.history.replaceState({}, '', '/login')
|
||||
if (data.token) {
|
||||
await loadUser()
|
||||
navigate('/dashboard', { replace: true })
|
||||
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard'
|
||||
sessionStorage.removeItem('oidc_redirect')
|
||||
navigate(savedRedirect, { replace: true })
|
||||
} else {
|
||||
setError(data.error || t('login.oidcFailed'))
|
||||
}
|
||||
@@ -104,6 +112,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
invalid_state: t('login.oidc.invalidState'),
|
||||
}
|
||||
setError(errorMessages[oidcError] || oidcError)
|
||||
sessionStorage.removeItem('oidc_redirect')
|
||||
window.history.replaceState({}, '', '/login')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
||||
}
|
||||
|
||||
function handleLoginRedirect() {
|
||||
const next = '/oauth/authorize?' + params.toString()
|
||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
|
||||
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'
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -184,7 +185,7 @@ export default function SharedTripPage() {
|
||||
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 dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
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 })),
|
||||
|
||||
@@ -666,15 +666,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const handleSaveTransport = async (data) => {
|
||||
try {
|
||||
if (editingTransport) {
|
||||
await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
return r
|
||||
} else {
|
||||
await tripActions.addReservation(tripId, data)
|
||||
const r = await tripActions.addReservation(tripId, data)
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
return r
|
||||
}
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
@@ -1194,7 +1199,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Trip {
|
||||
export interface Day {
|
||||
id: number
|
||||
trip_id: number
|
||||
day_number?: number
|
||||
date: string
|
||||
title: string | null
|
||||
notes: string | null
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||
day.day_number ?? days.indexOf(day)
|
||||
|
||||
export const isDayInAccommodationRange = (
|
||||
day: Day,
|
||||
startDayId: number,
|
||||
endDayId: number,
|
||||
days: Day[],
|
||||
): boolean => {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
if (!startDay || !endDay) {
|
||||
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
|
||||
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
|
||||
// both endpoints are present in a fully-loaded trip's days list.
|
||||
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
|
||||
}
|
||||
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
|
||||
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.10",
|
||||
"version": "3.0.12",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ export function createApp(): express.Application {
|
||||
const app = express();
|
||||
|
||||
// Trust first proxy (nginx/Docker) for correct req.ip
|
||||
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ export function createApp(): express.Application {
|
||||
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
||||
else callback(new Error('Not allowed by CORS'));
|
||||
};
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
corsOrigin = false;
|
||||
} else {
|
||||
corsOrigin = true;
|
||||
}
|
||||
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
||||
const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
// HSTS is worth enabling any time we're serving production traffic,
|
||||
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
||||
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ENCRYPTION_KEY = _encryptionKey;
|
||||
// Must stay in sync with client/src/i18n/supportedLanguages.ts (canonical source).
|
||||
// Kept duplicated here because server and client are separate npm packages.
|
||||
const SUPPORTED_LANG_CODES = ['de', 'en', 'es', 'fr', 'hu', 'nl', 'br', 'cs', 'pl', 'ru', 'zh', 'zh-TW', 'it', 'ar'];
|
||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE || 'en';
|
||||
const rawDefaultLang = process.env.DEFAULT_LANGUAGE?.toLowerCase() || 'en';
|
||||
if (!SUPPORTED_LANG_CODES.includes(rawDefaultLang)) {
|
||||
console.warn(`DEFAULT_LANGUAGE="${rawDefaultLang}" is not supported. Falling back to "en". Supported: ${SUPPORTED_LANG_CODES.join(', ')}`);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const db = new Proxy({} as Database.Database, {
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env.DEMO_MODE === 'true') {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
|
||||
try {
|
||||
const { seedDemoData } = require('../demo/demo-seed');
|
||||
seedDemoData(_db);
|
||||
|
||||
@@ -6,7 +6,7 @@ import crypto from 'crypto';
|
||||
// are only relevant after the first user exists; at that point seeds have already
|
||||
// finished and skip via the userCount > 0 guard above.
|
||||
function isOidcOnlyConfigured(): boolean {
|
||||
if (process.env.OIDC_ONLY !== 'true') return false;
|
||||
if (process.env.OIDC_ONLY?.toLowerCase() !== 'true') return false;
|
||||
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -29,8 +29,9 @@ const server = app.listen(PORT, () => {
|
||||
const banner = [
|
||||
'──────────────────────────────────────',
|
||||
' TREK API started',
|
||||
` Version ${process.env.APP_VERSION}`,
|
||||
` Port: ${PORT}`,
|
||||
` Environment: ${process.env.NODE_ENV || 'development'}`,
|
||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
@@ -40,8 +41,8 @@ const server = app.listen(PORT, () => {
|
||||
'──────────────────────────────────────',
|
||||
];
|
||||
banner.forEach(l => console.log(l));
|
||||
if (process.env.DEMO_MODE === 'true') sLogInfo('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||
}
|
||||
scheduler.start();
|
||||
|
||||
@@ -105,7 +105,7 @@ const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
|
||||
|
||||
const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(authReq.user?.email)) {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) {
|
||||
res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ router.put('/default-user-settings', (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// ── Dev-only: test notification endpoints ──────────────────────────────────────
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'development') {
|
||||
const { send } = require('../services/notificationService');
|
||||
|
||||
router.post('/dev/test-notification', async (req: Request, res: Response) => {
|
||||
|
||||
@@ -168,7 +168,7 @@ router.put('/auto-settings', (req: Request, res: Response) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
res.status(500).json({
|
||||
error: 'Could not save auto-backup settings',
|
||||
detail: process.env.NODE_ENV !== 'production' ? msg : undefined,
|
||||
detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' });
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured'));
|
||||
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV === 'production') {
|
||||
if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https'));
|
||||
}
|
||||
|
||||
|
||||
+35
-47
@@ -2,6 +2,7 @@ import cron, { type ScheduledTask } from 'node-cron';
|
||||
import archiver from 'archiver';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { logInfo, logError } from './services/auditLog';
|
||||
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
@@ -79,11 +80,9 @@ async function runBackup(): Promise<void> {
|
||||
if (fs.existsSync(uploadsDir)) archive.directory(uploadsDir, 'uploads');
|
||||
archive.finalize();
|
||||
});
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li(`Auto-Backup created: ${filename}`);
|
||||
logInfo(`Auto-Backup created: ${filename}`);
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Auto-Backup: ${err instanceof Error ? err.message : err}`);
|
||||
if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath);
|
||||
return;
|
||||
}
|
||||
@@ -94,23 +93,28 @@ async function runBackup(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOldBackups(keepDays: number): void {
|
||||
function autoBackupTimestampMs(filename: string): number | null {
|
||||
// auto-backup-2026-04-27T00-00-00.zip → 2026-04-27T00:00:00
|
||||
const stamp = filename.slice('auto-backup-'.length, -'.zip'.length);
|
||||
const iso = stamp.replace(/T(\d{2})-(\d{2})-(\d{2})$/, 'T$1:$2:$3');
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
}
|
||||
|
||||
export function cleanupOldBackups(keepDays: number, now: number = Date.now()): void {
|
||||
try {
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - keepDays * MS_PER_DAY;
|
||||
const files = fs.readdirSync(backupsDir).filter(f => f.endsWith('.zip'));
|
||||
const cutoff = now - keepDays * 24 * 60 * 60 * 1000;
|
||||
const files = fs.readdirSync(backupsDir).filter(f => f.startsWith('auto-backup-') && f.endsWith('.zip'));
|
||||
for (const file of files) {
|
||||
const filePath = path.join(backupsDir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.birthtimeMs < cutoff) {
|
||||
const ageMs = autoBackupTimestampMs(file) ?? fs.statSync(filePath).mtimeMs;
|
||||
if (ageMs < cutoff) {
|
||||
fs.unlinkSync(filePath);
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li(`Auto-Backup old backup deleted: ${file}`);
|
||||
logInfo(`Auto-Backup old backup deleted: ${file}`);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Auto-Backup cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,16 +126,14 @@ function start(): void {
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.enabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li('Auto-Backup disabled');
|
||||
logInfo('Auto-Backup disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const expression = buildCronExpression(settings);
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
|
||||
const { logInfo: li2 } = require('./services/auditLog');
|
||||
li2(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
logInfo(`Auto-Backup scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
|
||||
}
|
||||
|
||||
// Demo mode: hourly reset of demo user data
|
||||
@@ -139,19 +141,17 @@ let demoTask: ScheduledTask | null = null;
|
||||
|
||||
function startDemoReset(): void {
|
||||
if (demoTask) { demoTask.stop(); demoTask = null; }
|
||||
if (process.env.DEMO_MODE !== 'true') return;
|
||||
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') return;
|
||||
|
||||
demoTask = cron.schedule('0 * * * *', () => {
|
||||
try {
|
||||
const { resetDemoUser } = require('./demo/demo-reset');
|
||||
resetDemoUser();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Demo reset: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Demo reset: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
const { logInfo: li3 } = require('./services/auditLog');
|
||||
li3('Demo hourly reset scheduled');
|
||||
logInfo('Demo hourly reset scheduled');
|
||||
}
|
||||
|
||||
// Trip reminders: daily check at 9 AM local time for trips starting tomorrow
|
||||
@@ -167,14 +167,12 @@ function startTripReminders(): void {
|
||||
const channelsRaw = getSetting('notification_channels') || getSetting('notification_channel') || 'none';
|
||||
const activeChannels = channelsRaw === 'none' ? [] : channelsRaw.split(',').map((c: string) => c.trim());
|
||||
if (!reminderEnabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li('Trip reminders: disabled in settings');
|
||||
logInfo('Trip reminders: disabled in settings');
|
||||
return;
|
||||
}
|
||||
|
||||
const tripCount = (db.prepare('SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date IS NOT NULL').get() as { c: number }).c;
|
||||
const { logInfo: liSetup } = require('./services/auditLog');
|
||||
liSetup(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
logInfo(`Trip reminders: enabled via [${activeChannels.join(',')}]${tripCount > 0 ? `, ${tripCount} trip(s) with active reminders` : ''}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -196,13 +194,11 @@ function startTripReminders(): void {
|
||||
await send({ event: 'trip_reminder', actorId: null, scope: 'trip', targetId: trip.id, params: { trip: trip.title, tripId: String(trip.id) } }).catch(() => {});
|
||||
}
|
||||
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
if (trips.length > 0) {
|
||||
li(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
|
||||
logInfo(`Trip reminders sent for ${trips.length} trip(s): ${trips.map(t => `"${t.title}" (${t.reminder_days}d)`).join(', ')}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Trip reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
@@ -222,12 +218,10 @@ function startTodoReminders(): void {
|
||||
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
|
||||
const enabled = getSetting('notify_todo_due') !== 'false';
|
||||
if (!enabled) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li('Todo due reminders: disabled in settings');
|
||||
logInfo('Todo due reminders: disabled in settings');
|
||||
return;
|
||||
}
|
||||
const { logInfo: liSetup } = require('./services/auditLog');
|
||||
liSetup(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
|
||||
logInfo(`Todo due reminders: enabled (lead ${TODO_REMINDER_LEAD_DAYS}d)`);
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
todoReminderTask = cron.schedule('0 9 * * *', async () => {
|
||||
@@ -271,13 +265,11 @@ function startTodoReminders(): void {
|
||||
db.prepare('UPDATE todo_items SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?').run(todo.id);
|
||||
}
|
||||
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
if (todos.length > 0) {
|
||||
li(`Todo reminders sent for ${todos.length} item(s)`);
|
||||
logInfo(`Todo reminders sent for ${todos.length} item(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Todo reminder check failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
@@ -294,8 +286,7 @@ function startVersionCheck(): void {
|
||||
const { checkAndNotifyVersion } = require('./services/adminService');
|
||||
await checkAndNotifyVersion();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Version check: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
@@ -313,12 +304,10 @@ function startIdempotencyCleanup(): void {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
const { logInfo: li } = require('./services/auditLog');
|
||||
li(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}, { timezone: tz });
|
||||
}
|
||||
@@ -340,8 +329,7 @@ function startTrekPhotoCacheCleanup(): void {
|
||||
const { sweepExpired } = require('./services/memories/trekPhotoCache');
|
||||
sweepExpired();
|
||||
} catch (err: unknown) {
|
||||
const { logError: le } = require('./services/auditLog');
|
||||
le(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
logError(`Trek photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { updateJwtSecret } from '../config';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
||||
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
|
||||
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
@@ -170,7 +171,7 @@ export function deleteUser(id: string, currentUserId: number) {
|
||||
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as { id: number; email: string } | undefined;
|
||||
if (!userToDel) return { error: 'User not found', status: 404 };
|
||||
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
deleteUserCompletely(userToDel.id);
|
||||
return { email: userToDel.email };
|
||||
}
|
||||
|
||||
@@ -287,7 +288,7 @@ export function updateOidcSettings(data: {
|
||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||
|
||||
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
if (process.env.DEMO_MODE?.toLowerCase() !== 'true') {
|
||||
return { error: 'Not found', status: 404 };
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
|
||||
import { createEphemeralToken } from './ephemeralTokens';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { deleteUserCompletely } from './userCleanupService';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
@@ -130,7 +131,7 @@ export function resolveAuthToggles(): {
|
||||
oidc_login: get('oidc_login') !== 'false',
|
||||
oidc_registration: get('oidc_registration') !== 'false',
|
||||
};
|
||||
if (process.env.OIDC_ONLY === 'true') {
|
||||
if (process.env.OIDC_ONLY?.toLowerCase() === 'true') {
|
||||
result.password_login = false;
|
||||
result.password_registration = false;
|
||||
}
|
||||
@@ -138,7 +139,7 @@ export function resolveAuthToggles(): {
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
||||
const oidcOnlyEnabled = process.env.OIDC_ONLY?.toLowerCase() === 'true' || get('oidc_only') === 'true';
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
||||
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
||||
@@ -252,7 +253,7 @@ export function getPendingMfaSecret(userId: number): string | null {
|
||||
|
||||
export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const isDemo = process.env.DEMO_MODE?.toLowerCase() === 'true';
|
||||
const toggles = resolveAuthToggles();
|
||||
const version: string = process.env.APP_VERSION ?? require('../../package.json').version;
|
||||
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||
@@ -527,7 +528,7 @@ export function deleteAccount(userId: number, userEmail: string, userRole: strin
|
||||
return { error: 'Cannot delete the last admin account', status: 400 };
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
deleteUserCompletely(userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ const COOKIE_NAME = 'trek_session';
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
if (process.env.COOKIE_SECURE === 'false') {
|
||||
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||
return buildOptions(clear, false);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
|
||||
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function send(payload: NotificationPayload): Promise<void> {
|
||||
const configEntry = EVENT_NOTIFICATION_CONFIG[event];
|
||||
if (!configEntry) {
|
||||
logDebug(`notificationService.send: unknown event type "${event}", using fallback`);
|
||||
if (process.env.NODE_ENV === 'development' && actorId != null) {
|
||||
if (process.env.NODE_ENV?.toLowerCase() === 'development' && actorId != null) {
|
||||
const devSender = (db.prepare('SELECT username, avatar FROM users WHERE id = ?').get(actorId) as { username: string; avatar: string | null } | undefined) ?? null;
|
||||
createNotificationForRecipient({
|
||||
type: 'simple',
|
||||
|
||||
@@ -117,10 +117,11 @@ export function generateDays(tripId: number | bigint | string, startDate: string
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow dated days (trip shrunk): convert to dateless instead of deleting
|
||||
const nullify = db.prepare('UPDATE days SET date = NULL, day_number = ? WHERE id = ?');
|
||||
// Overflow dated days (trip shrunk): delete them (issue #909).
|
||||
// Cascade removes their assignments, notes, and accommodations.
|
||||
const del = db.prepare('DELETE FROM days WHERE id = ?');
|
||||
for (let i = targetDates.length; i < dated.length; i++) {
|
||||
nullify.run(targetDates.length + (i - targetDates.length) + 1, dated[i].id);
|
||||
del.run(dated[i].id);
|
||||
}
|
||||
|
||||
// Any remaining unused dateless days: keep as dateless, just renumber.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { db } from '../db/database';
|
||||
|
||||
function cleanupUserReferences(userId: number): void {
|
||||
db.prepare('UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?').run(userId);
|
||||
db.prepare('UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM share_tokens WHERE created_by = ?').run(userId);
|
||||
db.prepare('DELETE FROM journey_share_tokens WHERE created_by = ?').run(userId);
|
||||
// Owned journeys cascade-delete their entries/contributors/share_tokens/photos via journey_id FKs
|
||||
db.prepare('DELETE FROM journeys WHERE user_id = ?').run(userId);
|
||||
// Entries authored on other users' journeys (not covered by the cascade above)
|
||||
db.prepare('DELETE FROM journey_entries WHERE author_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM journey_contributors WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
export function deleteUserCompletely(userId: number): void {
|
||||
const tx = db.transaction((id: number) => {
|
||||
cleanupUserReferences(id);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
});
|
||||
tx(userId);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import dns from 'node:dns/promises';
|
||||
import { Agent } from 'undici';
|
||||
|
||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK === 'true';
|
||||
const ALLOW_INTERNAL_NETWORK = process.env.ALLOW_INTERNAL_NETWORK?.toLowerCase() === 'true';
|
||||
|
||||
export interface SsrfResult {
|
||||
allowed: boolean;
|
||||
|
||||
@@ -41,7 +41,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken } from '../helpers/factories';
|
||||
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -148,6 +148,216 @@ describe('Admin user management', () => {
|
||||
expect(deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-005b — DELETE /admin/users/:id succeeds when user has FK references', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: target } = createUser(testDb);
|
||||
const { user: otherUser } = createUser(testDb);
|
||||
const { user: thirdUser } = createUser(testDb);
|
||||
|
||||
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||
// (trip survives deletion; only invited_by should become NULL)
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||
|
||||
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-admin-test', ?)").run(otherTrip.id, target.id);
|
||||
|
||||
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
|
||||
|
||||
// journey_contributors: target is a contributor on otherUser's journey
|
||||
const otherJourney = createJourney(testDb, otherUser.id);
|
||||
addJourneyContributor(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_entries: target authored an entry on otherUser's journey
|
||||
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_share_tokens: target created a share token for otherUser's journey
|
||||
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-admin-test', ?)").run(otherJourney.id, target.id);
|
||||
|
||||
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||
const sentNotif = testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, target.id, otherUser.id);
|
||||
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||
testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, otherUser.id, target.id);
|
||||
|
||||
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||
testDb.prepare(
|
||||
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||
).run(target.id, Date.now());
|
||||
|
||||
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||
const ownedJourney = createJourney(testDb, target.id);
|
||||
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||
|
||||
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||
const fileRow = testDb.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
|
||||
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||
const trekPhotoRow = testDb.prepare(
|
||||
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-admin-test', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-admin', 'immich');
|
||||
|
||||
// trips.user_id (CASCADE): target owns a trip
|
||||
const ownedTrip = createTrip(testDb, target.id);
|
||||
|
||||
// trip_members.user_id (CASCADE): target is a member of otherUser's trip
|
||||
addTripMember(testDb, otherTrip.id, target.id);
|
||||
|
||||
// categories.user_id (SET NULL): target created a category
|
||||
const userCategory = createCategory(testDb, { user_id: target.id });
|
||||
|
||||
// tags.user_id (CASCADE): target created a tag
|
||||
const userTag = createTag(testDb, target.id);
|
||||
|
||||
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
||||
const todoItem = createTodoItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||
|
||||
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||
const packBagRow = testDb.prepare(
|
||||
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
|
||||
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||
createMcpToken(testDb, target.id);
|
||||
|
||||
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-admin-test', ?, 'App', 'cid-admin-test', 'h')"
|
||||
).run(otherUser.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-admin-test', ?, 'ath-admin', 'rth-admin', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||
).run(target.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-admin-test', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||
|
||||
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
|
||||
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||
createBucketListItem(testDb, target.id);
|
||||
|
||||
// visited_countries.user_id (CASCADE): target has visited a country
|
||||
createVisitedCountry(testDb, target.id, 'JP');
|
||||
|
||||
// visited_regions.user_id (CASCADE): target has visited a region
|
||||
testDb.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||
).run(target.id);
|
||||
|
||||
// packing_templates.created_by (CASCADE): target created a packing template
|
||||
const packTemplateRow = testDb.prepare(
|
||||
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||
createInviteToken(testDb, { created_by: target.id });
|
||||
|
||||
// collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
|
||||
createCollabNote(testDb, otherTrip.id, target.id);
|
||||
|
||||
// settings.user_id (CASCADE): target has a user setting
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||
|
||||
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||
testDb.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-admin', datetime('now','+1 hour'))"
|
||||
).run(target.id);
|
||||
|
||||
// audit_log.user_id (SET NULL): target performed an audited action
|
||||
const auditRow = testDb.prepare(
|
||||
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||
).run(target.id);
|
||||
|
||||
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/admin/users/${target.id}`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||
// trip_members row survives but invited_by is now NULL
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// sent notification survives but sender_id becomes NULL
|
||||
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||
// received notification is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||
// notice dismissals are cascade-deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||
// owned journey and its entries are cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||
// uploaded file survives but uploaded_by is now NULL
|
||||
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||
// trek_photos row survives but owner_id is now NULL
|
||||
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||
// trip_photos row for target is cascade-deleted
|
||||
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||
// owned trip is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||
// trip membership on others' trips is removed
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||
// category survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||
// tag is deleted
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||
// todo assigned_user_id is NULL
|
||||
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||
// packing bag survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
// MCP tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// OAuth tokens and consents are deleted
|
||||
expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// owned vacay plan is deleted
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||
// vacay plan membership on others' plans is removed
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||
// bucket list items are deleted
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// travel history is deleted
|
||||
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// packing template is deleted
|
||||
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||
// invite tokens created by target are deleted
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// collab content is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||
// user settings are deleted
|
||||
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||
// password reset tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// audit log entry survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
// notification channel preferences are deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ADMIN-006 — admin cannot delete their own account', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken } from '../helpers/factories';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
@@ -509,6 +509,225 @@ describe('Extended auth scenarios', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Account deletion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Account deletion', () => {
|
||||
it('AUTH-040 — DELETE /auth/me succeeds when user has FK references', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const { user: target } = createUser(testDb);
|
||||
const { user: otherUser } = createUser(testDb);
|
||||
const { user: thirdUser } = createUser(testDb);
|
||||
|
||||
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||
// (trip survives deletion; only invited_by should become NULL)
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||
|
||||
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id);
|
||||
|
||||
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
|
||||
|
||||
// journey_contributors: target is a contributor on otherUser's journey
|
||||
const otherJourney = createJourney(testDb, otherUser.id);
|
||||
addJourneyContributor(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_entries: target authored an entry on otherUser's journey
|
||||
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_share_tokens: target created a share token for otherUser's journey
|
||||
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id);
|
||||
|
||||
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||
const sentNotif = testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, target.id, otherUser.id);
|
||||
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||
testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, otherUser.id, target.id);
|
||||
|
||||
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||
testDb.prepare(
|
||||
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||
).run(target.id, Date.now());
|
||||
|
||||
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||
const ownedJourney = createJourney(testDb, target.id);
|
||||
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||
|
||||
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||
const fileRow = testDb.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
|
||||
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||
const trekPhotoRow = testDb.prepare(
|
||||
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
|
||||
|
||||
// trips.user_id (CASCADE): target owns a trip
|
||||
const ownedTrip = createTrip(testDb, target.id);
|
||||
|
||||
// trip_members.user_id (CASCADE): target is a member of otherUser's trip
|
||||
addTripMember(testDb, otherTrip.id, target.id);
|
||||
|
||||
// categories.user_id (SET NULL): target created a category
|
||||
const userCategory = createCategory(testDb, { user_id: target.id });
|
||||
|
||||
// tags.user_id (CASCADE): target created a tag
|
||||
const userTag = createTag(testDb, target.id);
|
||||
|
||||
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
||||
const todoItem = createTodoItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||
|
||||
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||
const packBagRow = testDb.prepare(
|
||||
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
|
||||
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||
createMcpToken(testDb, target.id);
|
||||
|
||||
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')"
|
||||
).run(otherUser.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||
).run(target.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||
|
||||
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
|
||||
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||
createBucketListItem(testDb, target.id);
|
||||
|
||||
// visited_countries.user_id (CASCADE): target has visited a country
|
||||
createVisitedCountry(testDb, target.id, 'JP');
|
||||
|
||||
// visited_regions.user_id (CASCADE): target has visited a region
|
||||
testDb.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||
).run(target.id);
|
||||
|
||||
// packing_templates.created_by (CASCADE): target created a packing template
|
||||
const packTemplateRow = testDb.prepare(
|
||||
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||
).run(target.id);
|
||||
|
||||
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||
createInviteToken(testDb, { created_by: target.id });
|
||||
|
||||
// collab_notes.user_id (CASCADE): target authored a collab note on otherUser's trip
|
||||
createCollabNote(testDb, otherTrip.id, target.id);
|
||||
|
||||
// settings.user_id (CASCADE): target has a user setting
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||
|
||||
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||
testDb.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))"
|
||||
).run(target.id);
|
||||
|
||||
// audit_log.user_id (SET NULL): target performed an audited action
|
||||
const auditRow = testDb.prepare(
|
||||
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||
).run(target.id);
|
||||
|
||||
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||
|
||||
// admin exists to ensure target (non-admin user) passes the last-admin guard
|
||||
void admin;
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/auth/me')
|
||||
.set('Cookie', authCookie(target.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||
// trip_members row survives but invited_by is now NULL
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// sent notification survives but sender_id becomes NULL
|
||||
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||
// received notification is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||
// notice dismissals are cascade-deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||
// owned journey and its entries are cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||
// uploaded file survives but uploaded_by is now NULL
|
||||
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||
// trek_photos row survives but owner_id is now NULL
|
||||
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||
// trip_photos row for target is cascade-deleted
|
||||
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||
// owned trip is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||
// trip membership on others' trips is removed
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||
// category survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||
// tag is deleted
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||
// todo assigned_user_id is NULL
|
||||
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||
// packing bag survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
// MCP tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// OAuth tokens and consents are deleted
|
||||
expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// owned vacay plan is deleted
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||
// vacay plan membership on others' plans is removed
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||
// bucket list items are deleted
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// travel history is deleted
|
||||
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// packing template is deleted
|
||||
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||
// invite tokens created by target are deleted
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
// collab content is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||
// user settings are deleted
|
||||
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||
// password reset tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
// audit log entry survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
// notification channel preferences are deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rate limiting (AUTH-004, AUTH-018) — placed last
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -463,7 +463,7 @@ describe('Update trip', () => {
|
||||
expect(notesAfter!.day_id).toBe(daysAfter[1].id);
|
||||
});
|
||||
|
||||
it('TRIP-024 — Shrinking trip date range keeps overflow days as dateless with content intact', async () => {
|
||||
it('TRIP-024 — Shrinking trip date range deletes overflow days and their content', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-09-01', end_date: '2026-09-05' });
|
||||
|
||||
@@ -481,13 +481,12 @@ describe('Update trip', () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const daysAfter = testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number; date: string | null }[];
|
||||
expect(daysAfter).toHaveLength(5);
|
||||
expect(daysAfter.filter(d => d.date !== null)).toHaveLength(3);
|
||||
expect(daysAfter.filter(d => d.date === null)).toHaveLength(2);
|
||||
expect(daysAfter).toHaveLength(3);
|
||||
expect(daysAfter.every(d => d.date !== null)).toBe(true);
|
||||
|
||||
// Overflow assignments survived
|
||||
// Overflow days and their assignments deleted
|
||||
const all = testDb.prepare('SELECT * FROM day_assignments WHERE id IN (?, ?)').all(a4.id, a5.id) as { id: number }[];
|
||||
expect(all).toHaveLength(2);
|
||||
expect(all).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Prevent node-cron from scheduling anything at import time
|
||||
vi.mock('node-cron', () => ({
|
||||
@@ -17,6 +17,7 @@ vi.mock('node:fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||
unlinkSync: vi.fn(),
|
||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||
},
|
||||
existsSync: vi.fn(() => false),
|
||||
@@ -25,14 +26,20 @@ vi.mock('node:fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
statSync: vi.fn(() => ({ mtime: new Date(), size: 0 })),
|
||||
unlinkSync: vi.fn(),
|
||||
createWriteStream: vi.fn(() => ({ on: vi.fn(), pipe: vi.fn() })),
|
||||
}));
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: { prepare: () => ({ all: vi.fn(() => []), get: vi.fn(), run: vi.fn() }) },
|
||||
}));
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
vi.mock('../../src/services/auditLog', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { buildCronExpression } from '../../src/scheduler';
|
||||
import fs from 'node:fs';
|
||||
import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler';
|
||||
|
||||
interface BackupSettings {
|
||||
enabled: boolean;
|
||||
@@ -130,3 +137,82 @@ describe('buildCronExpression', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldBackups', () => {
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = new Date('2026-04-27T02:00:00Z').getTime();
|
||||
|
||||
function isoFilename(daysAgo: number, prefix: 'auto-backup' | 'backup' = 'auto-backup'): string {
|
||||
const d = new Date(NOW - daysAgo * DAY);
|
||||
const stamp = d.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
return `${prefix}-${stamp}.zip`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fs.readdirSync).mockReset();
|
||||
vi.mocked(fs.statSync).mockReset();
|
||||
vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>).mockReset();
|
||||
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 });
|
||||
});
|
||||
|
||||
it('never deletes manual backup-*.zip files regardless of age', () => {
|
||||
const manual = isoFilename(365 * 5, 'backup');
|
||||
const auto = isoFilename(0);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([manual, auto] as unknown as string[]);
|
||||
cleanupOldBackups(7, NOW);
|
||||
const deleted = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls.map((c: unknown[]) => c[0] as string);
|
||||
expect(deleted.some((p: string) => p.includes(manual))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps auto-backups newer than retention', () => {
|
||||
const recent = isoFilename(3);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([recent] as unknown as string[]);
|
||||
cleanupOldBackups(7, NOW);
|
||||
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes auto-backups older than retention', () => {
|
||||
const old = isoFilename(30);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([old] as unknown as string[]);
|
||||
cleanupOldBackups(7, NOW);
|
||||
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
|
||||
const [calledPath] = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls[0] as string[];
|
||||
expect(calledPath).toContain(old);
|
||||
});
|
||||
|
||||
it('overlayfs regression: birthtimeMs=0 does not delete a same-day backup', () => {
|
||||
const fresh = isoFilename(0);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([fresh] as unknown as string[]);
|
||||
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 });
|
||||
cleanupOldBackups(7, NOW);
|
||||
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('malformed filename falls back to mtimeMs: keeps recent file', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
|
||||
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 });
|
||||
cleanupOldBackups(7, NOW);
|
||||
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('malformed filename falls back to mtimeMs: deletes stale file', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
|
||||
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 });
|
||||
cleanupOldBackups(7, NOW);
|
||||
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ignores non-zip files and does not crash', () => {
|
||||
const old = isoFilename(30);
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([old, 'notes.txt'] as unknown as string[]);
|
||||
cleanupOldBackups(7, NOW);
|
||||
const calls = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls as string[][];
|
||||
expect(calls.every(([p]: string[]) => !p.includes('notes.txt'))).toBe(true);
|
||||
expect(calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('swallows readdirSync errors without throwing', () => {
|
||||
vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT'); });
|
||||
expect(() => cleanupOldBackups(7, NOW)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,33 +96,37 @@ describe('generateDays', () => {
|
||||
expect(getNotes(day2.id)[0].id).toBe(note.id);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-011: shrinking range converts overflow days to dateless, preserves their assignments', () => {
|
||||
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
|
||||
const daysBefore = getDays(trip.id);
|
||||
expect(daysBefore).toHaveLength(5);
|
||||
|
||||
const place = createPlace(testDb, trip.id);
|
||||
// Assign places to days 4 and 5 (will become overflow)
|
||||
const a4 = createDayAssignment(testDb, daysBefore[3].id, place.id);
|
||||
const a5 = createDayAssignment(testDb, daysBefore[4].id, place.id);
|
||||
createDayAssignment(testDb, daysBefore[3].id, place.id);
|
||||
createDayAssignment(testDb, daysBefore[4].id, place.id);
|
||||
|
||||
// Shrink from 5 to 3 days
|
||||
// Shrink from 5 to 3 days — surplus days and their content are removed
|
||||
generateDays(trip.id, '2025-07-01', '2025-07-03');
|
||||
|
||||
const daysAfter = getDays(trip.id);
|
||||
expect(daysAfter).toHaveLength(5); // no rows deleted
|
||||
expect(daysAfter).toHaveLength(3);
|
||||
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
|
||||
});
|
||||
|
||||
const dated = daysAfter.filter(d => d.date !== null);
|
||||
const dateless = daysAfter.filter(d => d.date === null);
|
||||
expect(dated).toHaveLength(3);
|
||||
expect(dateless).toHaveLength(2);
|
||||
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
|
||||
expect(getDays(trip.id)).toHaveLength(7);
|
||||
|
||||
// Overflow days still have their assignments
|
||||
expect(getAssignments(dateless[0].id)).toHaveLength(1);
|
||||
expect(getAssignments(dateless[0].id)[0].id).toBe(a4.id);
|
||||
expect(getAssignments(dateless[1].id)).toHaveLength(1);
|
||||
expect(getAssignments(dateless[1].id)[0].id).toBe(a5.id);
|
||||
// Shrink 7 → 5; days 6 and 7 have no content
|
||||
generateDays(trip.id, '2025-07-01', '2025-07-05');
|
||||
|
||||
const daysAfter = getDays(trip.id);
|
||||
expect(daysAfter).toHaveLength(5);
|
||||
expect(daysAfter.map(d => d.date)).toEqual([
|
||||
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
|
||||
]);
|
||||
});
|
||||
|
||||
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
|
||||
|
||||
@@ -23,10 +23,21 @@ Items are sorted by their time or position index.
|
||||
## Assigning places to a day
|
||||
|
||||
- **Drag and drop** — drag a place from the right-hand Places sidebar and drop it onto a day section or between existing items.
|
||||
|
||||

|
||||
|
||||
- **Add button** — click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
|
||||
|
||||

|
||||
|
||||
- **Mobile** — tap the **Add Place** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
|
||||
|
||||
You can also reorder places within a day, or move them to a different day, by dragging and dropping inside the sidebar.
|
||||
|
||||
To remove a place from a day, click the **X** button next to the place in the day timeline.
|
||||
|
||||

|
||||
|
||||
## Multi-day reservations
|
||||
|
||||
A reservation that spans multiple days appears in each relevant day with a phase label:
|
||||
@@ -70,4 +81,4 @@ At the top of the Day Plan sidebar:
|
||||
|
||||
Route calculation controls (optimize order, open in Google Maps) appear inside each expanded day section after the place list.
|
||||
|
||||
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
|
||||
+28
-2
@@ -29,10 +29,25 @@ Go to **Settings → Integrations → Photo Providers**. Each enabled provider s
|
||||
|-------|----------|-------|
|
||||
| Server URL | Yes | Full URL of your Immich instance, e.g. `https://immich.example.com` |
|
||||
| API Key | Yes | Stored encrypted; never returned to the browser after saving |
|
||||
| Auto-upload to Immich | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
|
||||
| Mirror journey photos to Immich on upload | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
|
||||
|
||||
Enter the full URL of your Immich instance and an Immich API key. The API key is stored encrypted on the TREK server and is never returned to the browser after it is saved.
|
||||
|
||||
#### Required API key permissions
|
||||
|
||||
When generating the API key in Immich (**Account Settings → API Keys**), grant only the scopes TREK actually uses:
|
||||
|
||||
| Permission | Why TREK needs it |
|
||||
|------------|-------------------|
|
||||
| `user.read` | Verify the API key and identify the connected account |
|
||||
| `timeline.read` | Browse photos by date |
|
||||
| `asset.read` | Read photo metadata and search results |
|
||||
| `asset.view` | Load thumbnails and preview images |
|
||||
| `album.read` | List owned + shared albums and their contents |
|
||||
| `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library |
|
||||
|
||||
TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed.
|
||||
|
||||
### Synology Photos
|
||||
|
||||
| Field | Required | Notes |
|
||||
@@ -43,6 +58,17 @@ Enter the full URL of your Immich instance and an Immich API key. The API key is
|
||||
| OTP code | No | One-time password for 2FA; only needed on first connection or when re-authenticating |
|
||||
| Skip SSL verification | No | Checkbox; disable TLS certificate validation for self-signed certificates |
|
||||
|
||||
#### Required DSM account permissions
|
||||
|
||||
Synology Photos doesn't use API keys — TREK signs in with a regular DSM user account. To minimize blast radius, create a **dedicated low-privilege DSM user** for TREK rather than reusing your admin account:
|
||||
|
||||
- A standard (non-admin) DSM user account is sufficient.
|
||||
- The account must have access to the **Synology Photos** package (DSM → **Control Panel → User & Group → [user] → Applications**, allow Synology Photos).
|
||||
- The account must be able to log in to DSM (not disabled, not IP-blocked).
|
||||
- Network access to DSM (typically port `5000` HTTP / `5001` HTTPS, or your reverse-proxy host).
|
||||
- 2FA is supported — enter the OTP at first connection; TREK stores the resulting device token so you won't be re-prompted on subsequent saves.
|
||||
- Read-only access is enough — TREK only lists albums, lists items, runs searches, and fetches thumbnails. It never writes, uploads, or deletes.
|
||||
|
||||
---
|
||||
|
||||
## Testing the connection
|
||||
@@ -68,4 +94,4 @@ Once a provider is connected, you can browse and attach photos to your trips. Se
|
||||
## See also
|
||||
|
||||
- [Admin-Addons](Admin-Addons)
|
||||
- [Internal-Network-Access](Internal-Network-Access)
|
||||
- [Internal-Network-Access](Internal-Network-Access)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 916 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 MiB |
Reference in New Issue
Block a user