mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd |
@@ -62,6 +62,7 @@ body:
|
||||
- Docker (standalone)
|
||||
- Kubernetes / Helm
|
||||
- Unraid template
|
||||
- Proxmox Community Script
|
||||
- Sources
|
||||
- Other
|
||||
validations:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ on:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/wiki.yml'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -400,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
# Trademark Policy
|
||||
|
||||
## Introduction
|
||||
|
||||
This is the TREK project's policy for the use of our trademarks. While TREK is
|
||||
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
|
||||
license does not include a license to use our trademarks.
|
||||
|
||||
This policy describes how you may use our trademarks. Our goal is to strike a
|
||||
balance between: 1) our need to ensure that our trademarks remain reliable
|
||||
indicators of the software we release; and 2) our community members' desire to
|
||||
be full participants in the TREK project.
|
||||
|
||||
## Our trademarks
|
||||
|
||||
This policy covers the name "TREK" as well as any associated logos, trade dress,
|
||||
goodwill, or designs (our "Marks").
|
||||
|
||||
## In general
|
||||
|
||||
Whenever you use our Marks, you must always do so in a way that does not mislead
|
||||
anyone about exactly who is the source of the software. For example, you cannot
|
||||
say you are distributing TREK when you're distributing a modified version of it,
|
||||
because people would think they would be getting the same software that they
|
||||
can get directly from us when they aren't. You also cannot use our Marks on
|
||||
your website in a way that suggests that your website is an official TREK
|
||||
website or that we endorse your website. But, if true, you can say you like
|
||||
TREK, that you participate in the TREK community, that you are providing an
|
||||
unmodified version of TREK, or that you wrote a guide describing how to use
|
||||
TREK.
|
||||
|
||||
This fundamental requirement, that it is always clear to people what they are
|
||||
getting and from whom, is reflected throughout this policy. It should also
|
||||
serve as your guide if you are not sure about how you are using the Marks.
|
||||
|
||||
In addition:
|
||||
|
||||
* You may not use or register, in whole or in part, the Marks as part of your
|
||||
own trademark, service mark, domain name, company name, trade name, product
|
||||
name or service name.
|
||||
* Trademark law does not allow your use of names or trademarks that are too
|
||||
similar to ours. You therefore may not use an obvious variation of any of our
|
||||
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
|
||||
abbreviation for a similar or compatible product or service.
|
||||
* You agree that you will not acquire any rights in the Marks and that any
|
||||
goodwill generated by your use of the Marks and participation in our
|
||||
community inures solely to our benefit.
|
||||
|
||||
## Distribution of unmodified source code or unmodified executable code we have compiled
|
||||
|
||||
When you redistribute an unmodified copy of TREK, you are not changing the
|
||||
quality or nature of it. Therefore, you may retain the Marks we have placed on
|
||||
the software to identify your redistribution. This kind of use only applies if
|
||||
you are redistributing an official TREK distribution that has not been changed
|
||||
in any way.
|
||||
|
||||
## Distribution of executable code that you have compiled, or modified code
|
||||
|
||||
You may use the word mark "TREK", but not any TREK logos, to truthfully
|
||||
describe the origin of the software that you are providing, that is, that the
|
||||
code you are distributing is a modification of TREK. You may say, for example,
|
||||
that "this software is derived from the source code for TREK."
|
||||
|
||||
Of course, you can place your own trademarks or logos on versions of the
|
||||
software to which you have made substantive modifications, because by modifying
|
||||
the software, you have become the origin of that exact version. In that case,
|
||||
you should not use our Marks.
|
||||
|
||||
However, you may use our Marks for the distribution of code (source or
|
||||
executable) on the condition that any executable is built from an official TREK
|
||||
source code release and that any modifications are limited to switching on or
|
||||
off features already included in the software, translations into other
|
||||
languages, and incorporating minor bug-fix patches. Use of our Marks on any
|
||||
further modification is not permitted.
|
||||
|
||||
## Mobile wrappers, hosted instances, and forks
|
||||
|
||||
The following clarifications apply specifically to common ways TREK is
|
||||
redistributed:
|
||||
|
||||
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
|
||||
as "a TREK instance" or "running TREK." You may not name the service itself
|
||||
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
|
||||
Official," etc.).
|
||||
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
|
||||
TREK.** You may describe your app as "a mobile client for TREK" or "for use
|
||||
with TREK." You may not publish it on app stores under the name "TREK" or a
|
||||
confusingly similar name, and you may not use the TREK logo as the app icon
|
||||
unless your wrapper distributes only an unmodified, official TREK instance
|
||||
and you have obtained permission.
|
||||
* **Forks of the TREK source code.** Forks that diverge from upstream must use
|
||||
a different name. You may state that your fork is "based on TREK" or "a fork
|
||||
of TREK," but the project name itself must be your own.
|
||||
|
||||
## Statements about your software's relation to TREK
|
||||
|
||||
You may use the word mark, but not TREK logos, to truthfully describe the
|
||||
relationship between your software and ours. The word mark "TREK" should be
|
||||
used after a verb or preposition that describes the relationship between your
|
||||
software and ours. So you may say, for example, "Bob's app for TREK" but may
|
||||
not say "Bob's TREK app." Some other examples that may work for you are:
|
||||
|
||||
* [Your software] uses TREK
|
||||
* [Your software] is powered by TREK
|
||||
* [Your software] runs on TREK
|
||||
* [Your software] for use with TREK
|
||||
* [Your software] for TREK
|
||||
|
||||
## Questions and permission requests
|
||||
|
||||
If you are not sure whether your intended use of the Marks is permitted under
|
||||
this policy, or if you would like to request explicit permission for a use that
|
||||
is not covered, please open an issue on the TREK GitHub repository or contact
|
||||
the maintainers directly.
|
||||
|
||||
---
|
||||
|
||||
These guidelines are based on the
|
||||
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
|
||||
under a
|
||||
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.2
|
||||
version: 3.0.13
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.2"
|
||||
appVersion: "3.0.13"
|
||||
|
||||
@@ -22,6 +22,9 @@ data:
|
||||
{{- if .Values.env.FORCE_HTTPS }}
|
||||
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
|
||||
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -30,6 +30,8 @@ env:
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||
# HSTS_INCLUDE_SUBDOMAINS: "false"
|
||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.13",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -8907,9 +8907,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.13",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
const items = grouped.get(oldName) || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@ vi.mock('react-router-dom', async () => {
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BottomNav from './BottomNav';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
|
||||
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('My Trips')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
|
||||
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
|
||||
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Profil')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||
seedStore(useAddonStore, {
|
||||
addons: [
|
||||
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
|
||||
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
|
||||
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
|
||||
],
|
||||
});
|
||||
render(<BottomNav />);
|
||||
expect(screen.getByText('Vacances')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
|
||||
});
|
||||
render(<BottomNav />);
|
||||
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
||||
]
|
||||
|
||||
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
|
||||
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
|
||||
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
|
||||
journey: { to: '/journey', label: 'Journey', icon: Compass },
|
||||
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
|
||||
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
|
||||
}
|
||||
|
||||
export default function BottomNav() {
|
||||
@@ -25,11 +21,13 @@ export default function BottomNav() {
|
||||
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
|
||||
const [showProfile, setShowProfile] = useState(false)
|
||||
|
||||
const items = [...BASE_ITEMS]
|
||||
for (const addon of globalAddons) {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
if (nav) items.push(nav)
|
||||
}
|
||||
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
||||
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
||||
...globalAddons.flatMap(addon => {
|
||||
const nav = ADDON_NAV[addon.id]
|
||||
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||
}),
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: 16, overflow: 'auto',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
position: 'sticky', bottom: 0, background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||
<Github size={13} />
|
||||
|
||||
@@ -7,6 +7,16 @@ import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
|
||||
const mapMock = vi.hoisted(() => ({
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
|
||||
TileLayer: () => <div data-testid="tile-layer" />,
|
||||
@@ -27,15 +37,7 @@ vi.mock('react-leaflet', () => ({
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
useMap: () => ({
|
||||
panTo: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: () => 10,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
}),
|
||||
useMap: () => mapMock,
|
||||
useMapEvents: () => ({}),
|
||||
}))
|
||||
|
||||
@@ -79,6 +81,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
@@ -216,4 +219,33 @@ describe('MapView', () => {
|
||||
render(<MapView places={places} selectedPlaceId={5} />)
|
||||
expect(screen.getByTestId('marker')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
||||
]
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
||||
const initialCount = mapMock.fitBounds.mock.calls.length
|
||||
|
||||
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
|
||||
// paddingOpts memo creates new object). fitBounds must NOT fire again.
|
||||
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
||||
|
||||
// Toggle selectedPlaceId off — mimics closing inspector via X button.
|
||||
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
|
||||
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
]
|
||||
const { rerender } = render(<MapView places={places} fitKey={1} />)
|
||||
const afterFirst = mapMock.fitBounds.mock.calls.length
|
||||
|
||||
rerender(<MapView places={places} fitKey={2} />)
|
||||
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
||||
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -233,18 +233,7 @@ interface RouteLabelProps {
|
||||
}
|
||||
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const check = () => setVisible(map.getZoom() >= 12)
|
||||
check()
|
||||
map.on('zoomend', check)
|
||||
return () => map.off('zoomend', check)
|
||||
}, [map])
|
||||
|
||||
if (!visible || !midpoint) return null
|
||||
if (!midpoint) return null
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { act } from '@testing-library/react'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
// Stable fake map so fitBounds call counts survive re-renders.
|
||||
const glMap = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
loaded: vi.fn().mockReturnValue(true),
|
||||
fitBounds: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
jumpTo: vi.fn(),
|
||||
getZoom: vi.fn().mockReturnValue(10),
|
||||
addControl: vi.fn(),
|
||||
removeControl: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
addSource: vi.fn(),
|
||||
getSource: vi.fn().mockReturnValue(null),
|
||||
addLayer: vi.fn(),
|
||||
setLayoutProperty: vi.fn(),
|
||||
getStyle: vi.fn().mockReturnValue({ layers: [] }),
|
||||
isStyleLoaded: vi.fn().mockReturnValue(true),
|
||||
getCanvasContainer: vi.fn(() => document.createElement('div')),
|
||||
}))
|
||||
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(() => glMap),
|
||||
Marker: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
NavigationControl: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
vi.mock('./mapboxSetup', () => ({
|
||||
isStandardFamily: vi.fn(() => false),
|
||||
supportsCustom3d: vi.fn(() => false),
|
||||
wantsTerrain: vi.fn(() => false),
|
||||
addCustom3dBuildings: vi.fn(),
|
||||
addTerrainAndSky: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./locationMarkerMapbox', () => ({
|
||||
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('./reservationsMapbox', () => ({
|
||||
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
useGeolocation: vi.fn(() => ({
|
||||
position: null,
|
||||
mode: 'off',
|
||||
error: null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../services/photoService', () => ({
|
||||
getCached: vi.fn(() => null),
|
||||
isLoading: vi.fn(() => false),
|
||||
fetchPhoto: vi.fn(),
|
||||
onThumbReady: vi.fn(() => () => {}),
|
||||
getAllThumbs: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
import { MapViewGL } from './MapViewGL'
|
||||
|
||||
function buildMapPlace(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
...buildPlace(),
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
...overrides,
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
map_provider: 'mapbox-gl',
|
||||
mapbox_access_token: 'pk.test_token',
|
||||
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
|
||||
mapbox_3d_enabled: false,
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
describe('MapViewGL', () => {
|
||||
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
|
||||
]
|
||||
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
|
||||
)
|
||||
await act(async () => {})
|
||||
const after_initial = glMap.fitBounds.mock.calls.length
|
||||
|
||||
// Selecting a place flips hasInspector → paddingOpts memo changes.
|
||||
// fitBounds must NOT fire again (this was the bug).
|
||||
rerender(
|
||||
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
]
|
||||
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
|
||||
)
|
||||
await act(async () => {})
|
||||
const after_initial = glMap.fitBounds.mock.calls.length
|
||||
|
||||
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
|
||||
rerender(
|
||||
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
|
||||
)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
|
||||
const places = [
|
||||
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
|
||||
]
|
||||
|
||||
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
|
||||
await act(async () => {})
|
||||
const after_first = glMap.fitBounds.mock.calls.length
|
||||
|
||||
rerender(<MapViewGL places={places} fitKey={2} />)
|
||||
await act(async () => {})
|
||||
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
|
||||
})
|
||||
})
|
||||
@@ -507,13 +507,10 @@ export function MapViewGL({
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Also fit when the places collection changes so the initial render
|
||||
// zooms to the trip instead of the default center.
|
||||
const placeBoundsKey = useMemo(
|
||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
||||
[places]
|
||||
)
|
||||
const prevFitKey = useRef(-1)
|
||||
useEffect(() => {
|
||||
if (fitKey === prevFitKey.current) return
|
||||
prevFitKey.current = fitKey
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||
@@ -533,7 +530,7 @@ export function MapViewGL({
|
||||
}
|
||||
if (map.loaded()) run()
|
||||
else map.once('load', run)
|
||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
|
||||
@@ -78,6 +78,7 @@ const transportReservation = {
|
||||
id: 400,
|
||||
title: 'Flight to Rome',
|
||||
type: 'flight',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T14:30:00',
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||
|
||||
@@ -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 ''
|
||||
@@ -140,23 +141,58 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const totalCost = Object.values(assignments || {})
|
||||
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
|
||||
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
|
||||
const pdfGetDayOrder = (d: Day) => d.day_number
|
||||
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (!startId || startId === endId) return 'single'
|
||||
if (dayId === startId) return 'start'
|
||||
if (dayId === endId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
|
||||
const phase = pdfGetSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
|
||||
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
|
||||
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
const startId = r.day_id
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId == null) return false
|
||||
if (endId !== startId) {
|
||||
const startDay = sorted.find(d => d.id === startId)
|
||||
const endDay = sorted.find(d => d.id === endId)
|
||||
const thisDay = sorted.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
|
||||
}
|
||||
return startId === dayId
|
||||
})
|
||||
|
||||
// Build day HTML
|
||||
const daysHtml = sorted.map((day, di) => {
|
||||
const assigned = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Reservations for this day (hotel rendered via accommodations block)
|
||||
const dayReservations = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||
const dayReservations = pdfGetTransportForDay(day.id)
|
||||
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
@@ -177,13 +213,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||
@@ -246,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,
|
||||
@@ -66,7 +67,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const fmtTime = (v) => {
|
||||
if (!v) return v
|
||||
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||
return formatTime12(v, is12h)
|
||||
}
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const collapsed = collapsedProp
|
||||
const toggleCollapse = () => onToggleCollapse?.()
|
||||
@@ -95,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)
|
||||
@@ -126,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 })
|
||||
@@ -150,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?.()
|
||||
@@ -459,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 }),
|
||||
@@ -474,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 }),
|
||||
@@ -594,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?.()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
||||
|
||||
@@ -14,6 +14,7 @@ import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -21,6 +22,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'
|
||||
@@ -189,6 +191,8 @@ interface DayPlanSidebarProps {
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
initialScrollTop?: number
|
||||
onScrollTopChange?: (top: number) => void
|
||||
}
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
@@ -217,6 +221,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -269,6 +275,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
} | null>(null)
|
||||
const inputRef = useRef(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (scrollContainerRef.current && initialScrollTop) {
|
||||
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||
}
|
||||
}, [])
|
||||
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
// Remember which assignment we last auto-scrolled into view so we don't
|
||||
// keep yanking the user back whenever they scroll away while the same
|
||||
@@ -397,7 +409,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
|
||||
@@ -1116,7 +1128,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
|
||||
{/* Tagesliste */}
|
||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
@@ -1214,7 +1226,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
|
||||
@@ -1576,7 +1588,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
{res.reservation_end_time && ` – ${(() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
})()}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
@@ -1722,7 +1737,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()
|
||||
@@ -2220,7 +2239,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{res.notes && (
|
||||
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
|
||||
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -349,8 +350,8 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -399,7 +400,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
|
||||
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -34,6 +34,8 @@ interface PlacesSidebarProps {
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
onPlacesFilterChange?: (filter: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
initialScrollTop?: number
|
||||
onScrollTopChange?: (top: number) => void
|
||||
}
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
@@ -145,6 +147,7 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
initialScrollTop, onScrollTopChange,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -159,6 +162,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
||||
const sidebarDragCounter = useRef(0)
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (scrollContainerRef.current && initialScrollTop) {
|
||||
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
||||
if (!canEditPlaces) return
|
||||
@@ -636,7 +645,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
|
||||
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -723,4 +723,103 @@ describe('ReservationModal', () => {
|
||||
expect.objectContaining({ type: 'hotel' })
|
||||
);
|
||||
});
|
||||
|
||||
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
|
||||
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
|
||||
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
|
||||
|
||||
function buildNonMonotonicDaysRM() {
|
||||
return [
|
||||
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
|
||||
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
|
||||
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
|
||||
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
|
||||
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
|
||||
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
|
||||
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
|
||||
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
|
||||
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
|
||||
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
|
||||
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
|
||||
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
|
||||
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
|
||||
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
|
||||
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
|
||||
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
|
||||
] as any[];
|
||||
}
|
||||
|
||||
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const days = buildNonMonotonicDaysRM();
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
// Switch to hotel type
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
|
||||
|
||||
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
|
||||
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
|
||||
await userEvent.click(startTrigger());
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
|
||||
|
||||
// Open end picker and select Day 16 (id=7, low ID but last positionally)
|
||||
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
|
||||
await userEvent.click(endTrigger());
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const saved = onSave.mock.calls[0][0];
|
||||
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
|
||||
expect(saved.create_accommodation?.start_day_id).toBe(17);
|
||||
expect(saved.create_accommodation?.end_day_id).toBe(7);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const days = buildNonMonotonicDaysRM();
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
|
||||
|
||||
// Set end to Day 16 (id=7) first
|
||||
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
|
||||
await userEvent.click(endTrigger());
|
||||
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 id=7.
|
||||
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
|
||||
await userEvent.click(startTrigger());
|
||||
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const saved = onSave.mock.calls[0][0];
|
||||
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
|
||||
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
// Hotel reservation with assignment_id set but no accommodation
|
||||
const res = buildReservation({
|
||||
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
|
||||
accommodation_id: null, assignment_id: 99,
|
||||
} as any);
|
||||
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,6 +182,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
} else if (form.reservation_end_time && form.reservation_time) {
|
||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
@@ -194,7 +196,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
|
||||
location: form.location, confirmation_number: form.confirmation_number,
|
||||
notes: form.notes,
|
||||
assignment_id: form.assignment_id || null,
|
||||
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: [],
|
||||
@@ -457,7 +459,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
|
||||
<CustomSelect
|
||||
value={form.hotel_start_day}
|
||||
onChange={value => set('hotel_start_day', value)}
|
||||
onChange={value => setForm(prev => ({
|
||||
...prev,
|
||||
hotel_start_day: value,
|
||||
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
|
||||
? value : prev.hotel_end_day,
|
||||
}))}
|
||||
placeholder={t('reservations.meta.selectDay')}
|
||||
options={days.map(d => {
|
||||
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||
@@ -475,7 +482,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
|
||||
<CustomSelect
|
||||
value={form.hotel_end_day}
|
||||
onChange={value => set('hotel_end_day', value)}
|
||||
onChange={value => setForm(prev => ({
|
||||
...prev,
|
||||
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
|
||||
? value : prev.hotel_start_day,
|
||||
hotel_end_day: value,
|
||||
}))}
|
||||
placeholder={t('reservations.meta.selectDay')}
|
||||
options={days.map(d => {
|
||||
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
@@ -236,7 +239,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
|
||||
{(() => {
|
||||
const endDatePart = r.reservation_end_time
|
||||
? r.reservation_end_time.includes('T')
|
||||
? r.reservation_end_time.split('T')[0]
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||
? r.reservation_end_time
|
||||
: null
|
||||
: null
|
||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||
})() && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
</div>
|
||||
@@ -355,7 +367,9 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
{r.notes && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
|
||||
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
|
||||
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -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': '已移至回收站',
|
||||
|
||||
@@ -807,7 +807,7 @@ img[alt="TREK"] {
|
||||
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
|
||||
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
|
||||
.collab-note-md-full pre code { padding: 0; background: none; }
|
||||
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
|
||||
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; }
|
||||
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
|
||||
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -1474,6 +1474,56 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
|
||||
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
|
||||
|
||||
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
|
||||
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
|
||||
seedTripStore({ id: 42 });
|
||||
seedStore(useTripStore, {
|
||||
places: [place],
|
||||
assignments: { '99': [assignment] },
|
||||
} as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
act(() => { vi.runAllTimers(); });
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
|
||||
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
|
||||
b => b.textContent === 'Plan' && !b.getAttribute('title'),
|
||||
);
|
||||
expect(mobilePlanBtn).toBeTruthy();
|
||||
await act(async () => { fireEvent.click(mobilePlanBtn!); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
||||
});
|
||||
|
||||
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
|
||||
// so current holds the mobile portal instance's props.
|
||||
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
|
||||
expect(typeof mobileOnPlaceClick).toBe('function');
|
||||
|
||||
await act(async () => {
|
||||
mobileOnPlaceClick(place.id, assignment.id);
|
||||
});
|
||||
|
||||
// Invariant: portal must NOT unmount — both instances persist.
|
||||
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
|
||||
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
|
||||
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
|
||||
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -272,6 +272,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(null)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
const mobilePlanScrollTopRef = useRef<number>(0)
|
||||
const mobilePlacesScrollTopRef = useRef<number>(0)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||
|
||||
@@ -343,7 +345,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) tripActions.loadReservations(tripId)
|
||||
if (tripId) {
|
||||
tripActions.loadReservations(tripId)
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
@@ -663,15 +668,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')) }
|
||||
}
|
||||
|
||||
@@ -1106,8 +1116,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1191,7 +1201,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)}
|
||||
|
||||
@@ -355,6 +355,37 @@ describe('journeyStore', () => {
|
||||
expect(useJourneyStore.getState().loading).toBe(false);
|
||||
});
|
||||
|
||||
// ── reorderEntries ───────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
|
||||
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
|
||||
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
|
||||
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
|
||||
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
|
||||
);
|
||||
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([202, 201, 203]);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
|
||||
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
|
||||
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
|
||||
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
|
||||
);
|
||||
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
|
||||
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
|
||||
expect(ids).toEqual([211, 212]);
|
||||
});
|
||||
|
||||
// ── clear ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-STORE-JOURNEY-015: clear resets state', () => {
|
||||
|
||||
@@ -223,10 +223,8 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
)
|
||||
entries.sort((a, b) => {
|
||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||
const atime = a.entry_time || ''
|
||||
const btime = b.entry_time || ''
|
||||
if (atime !== btime) return atime.localeCompare(btime)
|
||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
if (a.sort_order !== b.sort_order) return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
return a.id - b.id
|
||||
})
|
||||
return { current: { ...s.current, entries } }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,6 +32,13 @@ function triggerAnchorDownload(blobUrl: string, filename?: string): void {
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 100)
|
||||
}
|
||||
|
||||
// navigator.standalone is true only on iOS when running as an
|
||||
// add-to-home-screen PWA. In that context, target="_blank" hands off to
|
||||
// Safari, which cannot access blob URLs sandboxed to the WebView.
|
||||
function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
@@ -56,7 +63,13 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* (including text/html and image/svg+xml which can execute script) are forced
|
||||
* to download so that an uploaded file cannot run code in the TREK origin.
|
||||
*
|
||||
* Falls back to a download trigger if the popup is blocked.
|
||||
* Uses a synthetic <a target="_blank" rel="noopener noreferrer"> click rather
|
||||
* than window.open(). window.open() called with the "noreferrer"/"noopener"
|
||||
* window feature returns null per spec, which previously made the popup-block
|
||||
* fallback trigger a download in the *current* tab on top of the new-tab open
|
||||
* — i.e. the file opened twice. The anchor approach avoids that ambiguity:
|
||||
* the new tab is opened by the browser's normal link-handling path, and no
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
@@ -71,11 +84,19 @@ export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
const win = window.open(blobUrl, '_blank', 'noreferrer')
|
||||
if (win) {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
} else {
|
||||
// Popup blocked — fall back to download
|
||||
// iOS PWA: target="_blank" would open Safari, which can't access the blob
|
||||
if (isIosStandalone()) {
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
return
|
||||
}
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
// Keep the blob URL alive long enough for the new tab to load it, then
|
||||
// clean up the DOM node and revoke the URL.
|
||||
setTimeout(() => { URL.revokeObjectURL(blobUrl); a.remove() }, 30_000)
|
||||
}
|
||||
|
||||
@@ -74,32 +74,42 @@ describe('downloadFile', () => {
|
||||
})
|
||||
|
||||
describe('openFile', () => {
|
||||
it('fetches with credentials:include and opens blob URL in new tab', async () => {
|
||||
it('fetches with credentials:include and opens blob URL via target=_blank anchor', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const mockWin = { closed: false }
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(mockWin as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledWith('/uploads/files/doc.pdf', { credentials: 'include' })
|
||||
expect(URL.createObjectURL).toHaveBeenCalled()
|
||||
expect(openSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noreferrer')
|
||||
// Must NOT call window.open — that path returns null when noreferrer is
|
||||
// set, which previously caused the file to also open in the current tab.
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// The anchor used to open the new tab must be target=_blank, must NOT
|
||||
// carry a `download` attribute (otherwise it would download in-page
|
||||
// instead of opening), and must use rel=noopener noreferrer.
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('_blank')
|
||||
expect(anchor.rel).toBe('noopener noreferrer')
|
||||
expect(anchor.hasAttribute('download')).toBe(false)
|
||||
|
||||
// Revoke happens after 30s timeout
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
|
||||
it('falls back to anchor download when popup is blocked', async () => {
|
||||
it('does not trigger a second in-page action for safe inline types (regression: no double-open)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/doc.pdf')
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
// Exactly ONE anchor click — opening the new tab. No fallback download.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on 401 response', async () => {
|
||||
@@ -108,28 +118,55 @@ describe('openFile', () => {
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces download for unsafe MIME types (HTML, SVG) instead of opening inline', async () => {
|
||||
it('forces download for unsafe MIME types (HTML) instead of opening inline', async () => {
|
||||
const htmlBlob = new Blob(['<script>alert(1)</script>'], { type: 'text/html' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, htmlBlob))
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.html')
|
||||
await openFile('/uploads/files/malicious.html', 'malicious.html')
|
||||
|
||||
// Must NOT open inline — download anchor clicked instead
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.download).toBe('malicious.html')
|
||||
})
|
||||
|
||||
it('forces download for SVG MIME type', async () => {
|
||||
const svgBlob = new Blob(['<svg><script>alert(1)</script></svg>'], { type: 'image/svg+xml' })
|
||||
vi.stubGlobal('fetch', makeFetchMock(200, svgBlob))
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue({} as Window)
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await openFile('/uploads/files/malicious.svg')
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back to download in iOS PWA standalone mode (blob URL inaccessible to Safari)', async () => {
|
||||
vi.stubGlobal('fetch', makeFetchMock(200))
|
||||
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
// Simulate iOS PWA (Add-to-Home-Screen) context
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: true })
|
||||
|
||||
try {
|
||||
await openFile('/uploads/files/doc.pdf', 'doc.pdf')
|
||||
|
||||
// Single anchor click — and it must be a DOWNLOAD anchor (no target=_blank),
|
||||
// because target="_blank" in iOS PWA would hand off to Safari which cannot
|
||||
// read the in-WebView blob URL.
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
const appendCalls = (document.body.appendChild as ReturnType<typeof vi.fn>).mock.calls
|
||||
const anchor = appendCalls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.target).toBe('')
|
||||
expect(anchor.download).toBe('doc.pdf')
|
||||
} finally {
|
||||
// Clean up the non-standard iOS-only property we forced above.
|
||||
delete (navigator as any).standalone
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=false # Set to true if Immich or other services are hosted on your local network (RFC-1918 IPs). Loopback and link-local addresses remain blocked regardless.
|
||||
|
||||
@@ -13,6 +13,7 @@ LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level detail
|
||||
|
||||
ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links
|
||||
FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy.
|
||||
# HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active (FORCE_HTTPS=true or NODE_ENV=production). Leave false if you run other services on sibling subdomains over plain HTTP.
|
||||
COOKIE_SECURE=true # Auto-derived (true when NODE_ENV=production or FORCE_HTTPS=true). Set false to force cookies over plain HTTP.
|
||||
TRUST_PROXY=1 # Trusted proxy hops (parseInt or 1). Active in production by default; off in dev unless set. Needed for FORCE_HTTPS.
|
||||
ALLOW_INTERNAL_NETWORK=false # Allow outbound requests to private/RFC1918 IPs (e.g. Immich hosted on your LAN). Loopback and link-local addresses are always blocked.
|
||||
|
||||
Generated
+913
-589
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.2",
|
||||
"version": "3.0.13",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
@@ -23,6 +23,7 @@
|
||||
"express": "^4.18.3",
|
||||
"fast-xml-parser": "^5.5.10",
|
||||
"helmet": "^8.1.0",
|
||||
"jimp": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -30,12 +31,11 @@
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "^7.7.4",
|
||||
"sharp": "^0.34.5",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^9.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
+4
-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
|
||||
@@ -124,6 +124,7 @@ export function createApp(): express.Application {
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
||||
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
||||
}));
|
||||
|
||||
if (shouldForceHttps) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2107,6 +2107,40 @@ function runMigrations(db: Database.Database): void {
|
||||
!= substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
// #846: make sort_order authoritative within a day. Previous ORDER BY put
|
||||
// entry_time before sort_order, silently ignoring reorder clicks when two
|
||||
// same-date entries had different times. Backfill renumbers using the old
|
||||
// effective key (entry_time ASC, id ASC) so existing journeys retain their
|
||||
// current visual order.
|
||||
() => {
|
||||
db.exec(`
|
||||
WITH ranked AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY journey_id, entry_date
|
||||
ORDER BY entry_time ASC, id ASC
|
||||
) - 1 AS rn
|
||||
FROM journey_entries
|
||||
)
|
||||
UPDATE journey_entries
|
||||
SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id)
|
||||
`);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' +
|
||||
'ON journey_entries(journey_id, entry_date, sort_order)'
|
||||
);
|
||||
},
|
||||
// Swap inverted start_day_id/end_day_id pairs in day_accommodations caused
|
||||
// by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs
|
||||
// instead of positional order on trips with non-monotonic day ID layouts.
|
||||
() => {
|
||||
db.exec(`
|
||||
UPDATE day_accommodations
|
||||
SET start_day_id = end_day_id, end_day_id = start_day_id
|
||||
WHERE (SELECT day_number FROM days WHERE id = start_day_id)
|
||||
> (SELECT day_number FROM days WHERE id = end_day_id)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,10 +117,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque
|
||||
|
||||
if (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
const { linkedReservationId } = dayService.deleteAccommodation(id);
|
||||
const { linkedReservationId, deletedBudgetItemId } = dayService.deleteAccommodation(id);
|
||||
if (linkedReservationId) {
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ router.get('/callback', async (req: Request, res: Response) => {
|
||||
tokenData.id_token,
|
||||
doc,
|
||||
config.clientId,
|
||||
config.issuer,
|
||||
(doc.issuer ?? '').replace(/\/+$/, '') || config.issuer,
|
||||
);
|
||||
if (idVerify.ok !== true) {
|
||||
const reason = 'error' in idVerify ? idVerify.error : 'unknown';
|
||||
|
||||
@@ -129,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,12 +179,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
|
||||
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { deleted: reservation, accommodationDeleted } = deleteReservation(id, tripId);
|
||||
const { deleted: reservation, accommodationDeleted, deletedBudgetItemId } = deleteReservation(id, tripId);
|
||||
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
|
||||
|
||||
if (accommodationDeleted) {
|
||||
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
if (deletedBudgetItemId) {
|
||||
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -292,14 +292,19 @@ export function updateAccommodation(id: string | number, existing: DayAccommodat
|
||||
return getAccommodationWithPlace(Number(id));
|
||||
}
|
||||
|
||||
/** Delete accommodation and its linked reservation. Returns the linked reservation id if one existed. */
|
||||
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null } {
|
||||
// Delete linked reservation
|
||||
/** Delete accommodation and its linked reservation (and any linked budget item). */
|
||||
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } {
|
||||
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
|
||||
let deletedBudgetItemId: number | null = null;
|
||||
if (linkedRes) {
|
||||
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(linkedRes.id) as { id: number } | undefined;
|
||||
if (linkedBudget) {
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
|
||||
deletedBudgetItemId = linkedBudget.id;
|
||||
}
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
|
||||
return { linkedReservationId: linkedRes ? linkedRes.id : null };
|
||||
return { linkedReservationId: linkedRes ? linkedRes.id : null, deletedBudgetItemId };
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function getJourneyFull(journeyId: number, userId: number) {
|
||||
if (!journey) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
@@ -306,12 +306,21 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
).all(journeyId, tripId) as { source_place_id: number }[];
|
||||
const existingPlaceIds = new Set(existing.map(e => e.source_place_id));
|
||||
|
||||
// Track next sort_order per date so synced skeletons get unique, sequential positions.
|
||||
const dateMaxOrder = new Map<string, number>();
|
||||
const maxRows = db.prepare(
|
||||
'SELECT entry_date, COALESCE(MAX(sort_order), -1) AS m FROM journey_entries WHERE journey_id = ? GROUP BY entry_date'
|
||||
).all(journeyId) as { entry_date: string; m: number }[];
|
||||
for (const row of maxRows) dateMaxOrder.set(row.entry_date, row.m);
|
||||
|
||||
for (const place of places) {
|
||||
if (existingPlaceIds.has(place.id)) continue;
|
||||
existingPlaceIds.add(place.id);
|
||||
|
||||
const entryDate = place.day_date || new Date().toISOString().split('T')[0];
|
||||
const entryTime = place.assignment_time || place.place_time || null;
|
||||
const nextOrder = (dateMaxOrder.get(entryDate) ?? -1) + 1;
|
||||
dateMaxOrder.set(entryDate, nextOrder);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
@@ -320,7 +329,7 @@ export function syncTripPlaces(journeyId: number, tripId: number, authorId: numb
|
||||
journeyId, tripId, place.id, authorId,
|
||||
place.name, entryDate, entryTime,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
place.day_number || 0, now, now
|
||||
nextOrder, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -367,15 +376,19 @@ export function onPlaceCreated(tripId: number, placeId: number) {
|
||||
|
||||
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(link.journey_id) as { user_id: number };
|
||||
const entryDate = place.day_date;
|
||||
const maxOrder = db.prepare(
|
||||
'SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?'
|
||||
).get(link.journey_id, entryDate) as { m: number | null };
|
||||
const nextOrder = (maxOrder?.m ?? -1) + 1;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, entry_time, location_name, location_lat, location_lng, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
link.journey_id, tripId, placeId, journey.user_id,
|
||||
place.name, entryDate, place.assignment_time || place.place_time || null,
|
||||
place.address || place.name, place.lat || null, place.lng || null,
|
||||
now, now
|
||||
nextOrder, now, now
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -451,7 +464,7 @@ export function listEntries(journeyId: number, userId: number) {
|
||||
if (!canAccessJourney(journeyId, userId)) return null;
|
||||
|
||||
const entries = db.prepare(
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, entry_time ASC, sort_order ASC'
|
||||
'SELECT * FROM journey_entries WHERE journey_id = ? ORDER BY entry_date ASC, sort_order ASC, id ASC'
|
||||
).all(journeyId) as JourneyEntry[];
|
||||
|
||||
const photos = db.prepare(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import sharp from 'sharp'
|
||||
import { Jimp } from 'jimp'
|
||||
import path from 'path'
|
||||
import fs from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { isAddonEnabled } from '../adminService'
|
||||
import { ADDON_IDS } from '../../addons'
|
||||
|
||||
const THUMB_MAX = 800
|
||||
const THUMB_QUALITY = 80
|
||||
@@ -10,12 +12,14 @@ export async function ensureLocalThumbnail(
|
||||
uploadsRoot: string,
|
||||
originalRelPath: string,
|
||||
): Promise<{ thumbnailRelPath: string; width: number; height: number } | null> {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return null
|
||||
|
||||
const originalAbs = path.join(uploadsRoot, originalRelPath)
|
||||
try { await fs.access(originalAbs) } catch { return null }
|
||||
|
||||
// Deterministic name so concurrent requests don't race on the same photo.
|
||||
const hash = crypto.createHash('sha1').update(originalRelPath).digest('hex').slice(0, 16)
|
||||
const thumbRel = `journey/thumbs/${hash}.webp`
|
||||
const thumbRel = `journey/thumbs/${hash}.jpg`
|
||||
const thumbAbs = path.join(uploadsRoot, thumbRel)
|
||||
|
||||
try {
|
||||
@@ -24,18 +28,21 @@ export async function ensureLocalThumbnail(
|
||||
fs.stat(thumbAbs).catch(() => null),
|
||||
])
|
||||
if (dstStat && dstStat.mtimeMs >= srcStat.mtimeMs) {
|
||||
const meta = await sharp(thumbAbs).metadata()
|
||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||
const img = await Jimp.read(thumbAbs)
|
||||
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
||||
await sharp(originalAbs)
|
||||
.rotate()
|
||||
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
||||
.webp({ quality: THUMB_QUALITY })
|
||||
.toFile(thumbAbs)
|
||||
const meta = await sharp(thumbAbs).metadata()
|
||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||
|
||||
// Jimp auto-applies EXIF orientation on read, matching sharp's .rotate() behavior.
|
||||
const img = await Jimp.read(originalAbs)
|
||||
const { width: w, height: h } = img.bitmap
|
||||
if (w > THUMB_MAX || h > THUMB_MAX) {
|
||||
img.scaleToFit({ w: THUMB_MAX, h: THUMB_MAX })
|
||||
}
|
||||
await img.write(thumbAbs as `${string}.jpg`, { quality: THUMB_QUALITY })
|
||||
|
||||
return { thumbnailRelPath: thumbRel, width: img.bitmap.width, height: img.bitmap.height }
|
||||
} catch {
|
||||
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
||||
return null
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -140,11 +140,21 @@ export async function discover(issuer: string, discoveryUrl?: string | null): Pr
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Failed to fetch OIDC discovery document');
|
||||
const doc = (await res.json()) as OidcDiscoveryDoc;
|
||||
// Validate that the discovery doc's issuer matches the operator-configured
|
||||
// one. A MITM or compromised doc could otherwise supply a crafted issuer
|
||||
// that passes jwt.verify() because we used doc.issuer as the expected value.
|
||||
if (doc.issuer && doc.issuer.replace(/\/+$/, '') !== issuer) {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
// Validate that the discovery doc's issuer matches the operator-configured one.
|
||||
// When no custom discoveryUrl is set, a mismatch signals a MITM or misconfiguration
|
||||
// and we reject. When the operator explicitly overrides the discovery URL (e.g.
|
||||
// Authentik realm paths), the discovery doc's issuer is the canonical value —
|
||||
// trust it and warn rather than blocking login.
|
||||
const docIssuer = doc.issuer?.replace(/\/+$/, '') ?? '';
|
||||
if (docIssuer && docIssuer !== issuer) {
|
||||
if (discoveryUrl) {
|
||||
console.warn(
|
||||
`[OIDC] Discovery doc issuer "${doc.issuer}" differs from configured OIDC_ISSUER "${issuer}". ` +
|
||||
`Using discovery doc issuer for id_token verification (custom OIDC_DISCOVERY_URL is set).`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(`OIDC discovery issuer mismatch: expected "${issuer}", got "${doc.issuer}"`);
|
||||
}
|
||||
}
|
||||
doc._issuer = url;
|
||||
discoveryCache = doc;
|
||||
@@ -313,7 +323,6 @@ export async function verifyIdToken(
|
||||
try {
|
||||
const verified = jwt.verify(idToken, publicKey, {
|
||||
algorithms: [alg as jwt.Algorithm],
|
||||
issuer: expectedIssuer,
|
||||
audience: clientId,
|
||||
});
|
||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||
@@ -322,6 +331,13 @@ export async function verifyIdToken(
|
||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
||||
}
|
||||
|
||||
// Normalize trailing slash before issuer comparison — some IdPs (e.g. Authentik)
|
||||
// include a trailing slash in the id_token iss claim.
|
||||
const tokenIssuer = typeof claims['iss'] === 'string' ? claims['iss'].replace(/\/+$/, '') : '';
|
||||
if (tokenIssuer !== expectedIssuer) {
|
||||
return { ok: false, error: `signature_or_claim_mismatch: jwt issuer invalid. expected: ${expectedIssuer}` };
|
||||
}
|
||||
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { avatarUrl } from './authService';
|
||||
|
||||
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
|
||||
|
||||
@@ -131,7 +132,10 @@ export function listBags(tripId: string | number) {
|
||||
if (!membersByBag.has(m.bag_id)) membersByBag.set(m.bag_id, []);
|
||||
membersByBag.get(m.bag_id)!.push(m);
|
||||
}
|
||||
return bags.map(b => ({ ...b, members: membersByBag.get(b.id) || [] }));
|
||||
return bags.map(b => ({
|
||||
...b,
|
||||
members: (membersByBag.get(b.id) || []).map(m => ({ ...m, avatar: avatarUrl(m) })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setBagMembers(tripId: string | number, bagId: string | number, userIds: number[]) {
|
||||
@@ -140,11 +144,12 @@ export function setBagMembers(tripId: string | number, bagId: string | number, u
|
||||
db.prepare('DELETE FROM packing_bag_members WHERE bag_id = ?').run(bagId);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of userIds) ins.run(bagId, uid);
|
||||
return db.prepare(`
|
||||
const rows = db.prepare(`
|
||||
SELECT bm.user_id, u.username, u.avatar
|
||||
FROM packing_bag_members bm JOIN users u ON bm.user_id = u.id
|
||||
WHERE bm.bag_id = ?
|
||||
`).all(bagId);
|
||||
`).all(bagId) as { user_id: number; username: string; avatar: string | null }[];
|
||||
return rows.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
export function createBag(tripId: string | number, data: { name: string; color?: string }) {
|
||||
@@ -260,7 +265,7 @@ export function getCategoryAssignees(tripId: string | number) {
|
||||
const assignees: Record<string, { user_id: number; username: string; avatar: string | null }[]> = {};
|
||||
for (const row of rows as any[]) {
|
||||
if (!assignees[row.category_name]) assignees[row.category_name] = [];
|
||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: row.avatar });
|
||||
assignees[row.category_name].push({ user_id: row.user_id, username: row.username, avatar: avatarUrl(row) });
|
||||
}
|
||||
|
||||
return assignees;
|
||||
@@ -274,12 +279,13 @@ export function updateCategoryAssignees(tripId: string | number, categoryName: s
|
||||
for (const uid of userIds) insert.run(tripId, categoryName, uid);
|
||||
}
|
||||
|
||||
return db.prepare(`
|
||||
const updated = db.prepare(`
|
||||
SELECT pca.user_id, u.username, u.avatar
|
||||
FROM packing_category_assignees pca
|
||||
JOIN users u ON pca.user_id = u.id
|
||||
WHERE pca.trip_id = ? AND pca.category_name = ?
|
||||
`).all(tripId, categoryName);
|
||||
`).all(tripId, categoryName) as { user_id: number; username: string; avatar: string | null }[];
|
||||
return updated.map(m => ({ ...m, avatar: avatarUrl(m) }));
|
||||
}
|
||||
|
||||
// ── Reorder ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,16 +61,24 @@ function resolveDayIdFromTime(
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
const saveEndpoints = db.transaction((reservationId: number, endpoints: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(reservationId);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
endpoints.forEach((e, i) => {
|
||||
insert.run(reservationId, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
function saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
|
||||
// Bind the transaction lazily on each call. Binding at module load time
|
||||
// captures the DB connection that was open then, which becomes invalid
|
||||
// after demo-reset / restore-from-backup closes and reinitialises the
|
||||
// connection — every later endpoint save would throw
|
||||
// "The database connection is not open".
|
||||
const tx = db.transaction((rid: number, eps: EndpointInput[]) => {
|
||||
db.prepare('DELETE FROM reservation_endpoints WHERE reservation_id = ?').run(rid);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO reservation_endpoints (reservation_id, role, sequence, name, code, lat, lng, timezone, local_time, local_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
eps.forEach((e, i) => {
|
||||
insert.run(rid, e.role, e.sequence ?? i, e.name, e.code ?? null, e.lat, e.lng, e.timezone ?? null, e.local_time ?? null, e.local_date ?? null);
|
||||
});
|
||||
});
|
||||
});
|
||||
tx(reservationId, endpoints);
|
||||
}
|
||||
|
||||
export function listReservations(tripId: string | number) {
|
||||
const reservations = db.prepare(`
|
||||
@@ -410,9 +418,9 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
return { reservation, accommodationChanged };
|
||||
}
|
||||
|
||||
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean } {
|
||||
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } {
|
||||
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
|
||||
if (!reservation) return { deleted: undefined, accommodationDeleted: false };
|
||||
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
|
||||
|
||||
let accommodationDeleted = false;
|
||||
if (reservation.accommodation_id) {
|
||||
@@ -420,6 +428,11 @@ export function deleteReservation(id: string | number, tripId: string | number):
|
||||
accommodationDeleted = true;
|
||||
}
|
||||
|
||||
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linkedBudget) {
|
||||
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
|
||||
return { deleted: reservation, accommodationDeleted };
|
||||
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -189,6 +189,25 @@ describe('Delete budget item', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(list.body.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('BUDGET-004b — DELETE budget item does NOT delete its linked reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id);
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/budget/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
|
||||
expect(reservationAfter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -502,4 +502,46 @@ describe('Accommodations', () => {
|
||||
).get(reservationBefore.id);
|
||||
expect(reservationAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Hotel Budget Trip' });
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
|
||||
|
||||
// Create a hotel reservation that creates an accommodation and a linked budget item
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Grand Hotel Stay',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
||||
create_budget_entry: { total_price: 450, category: 'Accommodation' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
|
||||
const accommodationId = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
expect(accommodationId).toBeDefined();
|
||||
|
||||
const budgetBefore = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
expect(budgetBefore).toBeDefined();
|
||||
|
||||
// Delete via the accommodation endpoint (the primary bug path)
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId.id}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const budgetAfter = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
expect(budgetAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -452,4 +452,41 @@ describe('Reservation accommodation delete', () => {
|
||||
).get(accom.id);
|
||||
expect(accomAfter).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-009b — DELETE reservation linked to accommodation also removes its linked budget item (issue #933)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-08-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-08-03' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Seaside Resort' });
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Seaside Resort Stay',
|
||||
type: 'hotel',
|
||||
day_id: day1.id,
|
||||
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
|
||||
create_budget_entry: { total_price: 320, category: 'Accommodation' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
const budgetBefore = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?'
|
||||
).get(trip.id, reservationId);
|
||||
expect(budgetBefore).toBeDefined();
|
||||
|
||||
// Delete via the reservation endpoint
|
||||
const delRes = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/reservations/${reservationId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const budgetAfter = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
expect(budgetAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user