mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed00b67ad | |||
| 4d072b4cb8 | |||
| 028e3e0a84 | |||
| 39b5af790e | |||
| 1eb2cb8eb2 | |||
| bcd2c8c959 | |||
| 5a9c14fc8e | |||
| 5500405f2f | |||
| 0a794583d7 | |||
| 4188f67ab7 | |||
| 8077ffab34 | |||
| 3e9626fce9 | |||
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 | |||
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 | |||
| f46cc8a98e | |||
| 1378c95078 | |||
| bb477645a3 | |||
| e65acb3de7 | |||
| 3c040fab11 | |||
| 49b3af8b0d | |||
| 093e069ccc | |||
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 |
@@ -34,5 +34,4 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
+2
-19
@@ -1,10 +1,3 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -51,7 +44,7 @@ COPY server/package.json ./server/
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
@@ -67,9 +60,6 @@ RUN apt-get update && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
@@ -85,10 +75,6 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
@@ -109,8 +95,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -91,7 +90,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -109,7 +108,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -130,13 +128,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -158,9 +156,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -194,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -205,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -266,7 +263,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -314,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -403,14 +397,12 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `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`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `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 |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server 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` |
|
||||
| `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` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.1.0
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.1.0"
|
||||
appVersion: "3.0.22"
|
||||
|
||||
@@ -28,12 +28,6 @@ data:
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION }}
|
||||
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -34,10 +34,6 @@ env:
|
||||
# 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.
|
||||
# SESSION_DURATION: "24h"
|
||||
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||
# SESSION_DURATION_REMEMBER: "30d"
|
||||
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||
# TRUST_PROXY: "1"
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
|
||||
+6
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -58,12 +58,11 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -81,8 +80,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.9"
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ export const addonsApi = {
|
||||
|
||||
export const airtrailApi = {
|
||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||
@@ -595,7 +595,6 @@ export const budgetApi = {
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useToast } from '../shared/Toast'
|
||||
import Section from '../Settings/Section'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
@@ -21,7 +20,6 @@ type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
@@ -228,23 +226,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
||||
placeholder={t('settings.currency')}
|
||||
searchable
|
||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 240 }}
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
||||
import CostsPanel from './CostsPanel'
|
||||
|
||||
const tripMembers = [
|
||||
{ id: 1, username: 'alice', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
||||
})
|
||||
|
||||
describe('CostsPanel — settlements in the ledger', () => {
|
||||
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
// The expense and the settlement (payment) both appear in the unified ledger.
|
||||
await screen.findByText('Dinner')
|
||||
await screen.findByText('Payment')
|
||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
||||
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records a manual payment via the Add payment button', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
||||
// The footer submit is the second "Add payment" control once the modal is open.
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
const submit = addButtons[addButtons.length - 1]
|
||||
await user.click(submit)
|
||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
||||
})
|
||||
|
||||
it('hides payment rows while a text search is active', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () =>
|
||||
HttpResponse.json({
|
||||
balances: [],
|
||||
flows: [],
|
||||
settlements: [
|
||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
||||
],
|
||||
})
|
||||
),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Payment')
|
||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
||||
// Payment rows have no name, so a search hides them while the matching expense stays.
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||
expect(nums()[2].value).toBe('50')
|
||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(100)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('marks an expense with no payer as Unfinished', async () => {
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
await screen.findByText('Hotel')
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -39,12 +39,6 @@ interface SettlementData {
|
||||
settlements: Settlement[]
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
// carrying the date used to group it by day.
|
||||
type LedgerEntry =
|
||||
| { kind: 'expense'; date: string; e: BudgetItem }
|
||||
| { kind: 'payment'; date: string; s: Settlement }
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||
|
||||
@@ -68,10 +62,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||
const [search, setSearch] = useState('')
|
||||
const [histOpen, setHistOpen] = useState(false)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
const people = tripMembers
|
||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||
@@ -129,37 +122,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return list
|
||||
}, [budgetItems, filter, search, me])
|
||||
|
||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
||||
// under "mine", only show transfers I'm part of.
|
||||
const filteredSettlements = useMemo(() => {
|
||||
if (search.trim()) return []
|
||||
if (filter === 'owed') return []
|
||||
let list = settlement?.settlements || []
|
||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||
return list
|
||||
}, [settlement, filter, search, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const entries: LedgerEntry[] = [
|
||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||
]
|
||||
const labelOf = (date: string) => {
|
||||
if (!date) return t('costs.noDate')
|
||||
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
|
||||
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||
const labelOf = (e: BudgetItem) => {
|
||||
if (!e.expense_date) return t('costs.noDate')
|
||||
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||
}
|
||||
// Newest day first; within a day, expenses before payments (insertion order).
|
||||
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
||||
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
||||
for (const en of sorted) {
|
||||
const day = labelOf(en.date)
|
||||
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||
for (const e of sorted) {
|
||||
const day = labelOf(e)
|
||||
let g = groups.find(x => x.day === day)
|
||||
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
||||
g.entries.push(en)
|
||||
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||
g.items.push(e)
|
||||
}
|
||||
return groups
|
||||
}, [filtered, filteredSettlements, locale, t])
|
||||
}, [filtered, locale, t])
|
||||
|
||||
// ── settle actions ──────────────────────────────────────────────────────
|
||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||
@@ -303,16 +280,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
||||
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -325,13 +300,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={13} /> {t('costs.addPayment')}
|
||||
</button>
|
||||
)}
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -357,11 +330,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{(editingSettlement || addingPayment) && (
|
||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||
</Modal>
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
@@ -467,9 +438,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||
{canEdit && (
|
||||
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
||||
)}
|
||||
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||
</div>
|
||||
<SettleFlows />
|
||||
</div>
|
||||
@@ -489,13 +458,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{dayGroups.length === 0
|
||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||
: dayGroups.map(g => {
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||
return (
|
||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -523,22 +490,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
||||
{isUnfinished && (
|
||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||
{payers.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||
{payers.map(p => (
|
||||
@@ -558,7 +514,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
@@ -575,32 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
||||
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
||||
function SettlementRow({ s }: { s: Settlement }) {
|
||||
return (
|
||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
||||
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
||||
{canEdit && (
|
||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||
@@ -703,75 +633,37 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
||||
)
|
||||
}
|
||||
|
||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
||||
// X" works the same whether or not there's an outstanding expense behind it.
|
||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
||||
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const amt = parseFloat(amount) || 0
|
||||
const valid = amt > 0 && fromId !== toId
|
||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
onSaved()
|
||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
||||
</div>
|
||||
}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.from')}</label>
|
||||
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.to')}</label>
|
||||
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.amount')}</label>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||
</div>
|
||||
</Modal>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{settlements.map(s => (
|
||||
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||
export interface ExpensePrefill {
|
||||
name?: string
|
||||
category?: string
|
||||
amount?: number
|
||||
reservationId?: number
|
||||
}
|
||||
|
||||
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -779,94 +671,34 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [name, setName] = useState(editing?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
// One participant list: a person is "in" the split and may have paid an amount.
|
||||
// Entering the total auto-distributes it equally across the non-pinned participants;
|
||||
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
||||
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
||||
// toward the trip total only, never settlements, until who-paid is filled in).
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
return ''
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
||||
// payer amounts load as pinned so opening an expense never reshuffles them.
|
||||
const [dirty, setDirty] = useState<Set<number>>(() =>
|
||||
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
||||
const [split, setSplit] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const totalNum = parseFloat(total) || 0
|
||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
||||
const paidEntered = paidSum > 0
|
||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
|
||||
|
||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
||||
const splitCents = (amount: number, n: number): number[] => {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const base = Math.floor(cents / n), rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
// Recompute the non-pinned participants so every paid amount sums to the total.
|
||||
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
||||
const ids = [...parts]
|
||||
const free = ids.filter(id => !dirtySet.has(id))
|
||||
if (free.length === 0) return paidMap
|
||||
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
||||
const shares = splitCents(totalVal - pinnedSum, free.length)
|
||||
const next = { ...paidMap }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
||||
return next
|
||||
}
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
setTotal(v)
|
||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||
}
|
||||
const onPaidChange = (id: number, v: string) => {
|
||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||
setDirty(nextDirty)
|
||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||
}
|
||||
const toggleParticipant = (id: number) => {
|
||||
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
||||
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
||||
else nextParts.add(id)
|
||||
setParticipants(nextParts); setDirty(nextDirty)
|
||||
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
||||
}
|
||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = [...participants]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||
const data = {
|
||||
name: name.trim(), category: cat,
|
||||
// Store the actual currency the amounts were entered in; conversion to the
|
||||
// viewer's display currency happens live (real rates), no manual rate.
|
||||
currency,
|
||||
payers: payerList, member_ids: [...participants],
|
||||
payers: payerList, member_ids: [...split],
|
||||
expense_date: day || null,
|
||||
// Always record the entered total: the server keeps it as-is for an unfinished
|
||||
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
||||
total_price: totalNum,
|
||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
@@ -896,9 +728,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
||||
onChange={e => onTotalChange(e.target.value)}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
@@ -914,11 +744,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum > 0 && (
|
||||
{currency !== base && payersTotal > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -943,37 +773,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map((p, idx) => {
|
||||
const on = participants.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
</button>
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||
)}
|
||||
{people.map(p => (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||
{people.map(p => {
|
||||
const on = split.has(p.id)
|
||||
return (
|
||||
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span className="text-content-faint">
|
||||
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</span>
|
||||
{paidEntered
|
||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
||||
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
||||
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import MarkdownToolbar from './MarkdownToolbar';
|
||||
import React from 'react';
|
||||
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
||||
}
|
||||
|
||||
describe('MarkdownToolbar', () => {
|
||||
let onUpdate: Mock<(value: string) => void>;
|
||||
let onUpdate: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate = vi.fn<(value: string) => void>();
|
||||
onUpdate = vi.fn();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||
|
||||
@@ -31,29 +31,21 @@ const glMap = vi.hoisted(() => ({
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
}
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return { extend: vi.fn().mockReturnThis() }
|
||||
}),
|
||||
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(),
|
||||
Popup: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
}),
|
||||
Popup: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
@@ -71,9 +63,7 @@ vi.mock('./locationMarkerMapbox', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./reservationsMapbox', () => ({
|
||||
ReservationMapboxOverlay: vi.fn(function () {
|
||||
return { update: vi.fn() }
|
||||
}),
|
||||
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
|
||||
@@ -84,22 +84,6 @@ const transportReservation = {
|
||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||
} as any
|
||||
|
||||
const multiLegFlight = {
|
||||
id: 401,
|
||||
title: 'Flight to Tokyo',
|
||||
type: 'flight',
|
||||
day_id: 10,
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
confirmation_number: 'XYZ789',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
|
||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
|
||||
],
|
||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||
}),
|
||||
} as any
|
||||
|
||||
const richArgs = {
|
||||
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
||||
days: [dayWithPlaces],
|
||||
@@ -212,16 +196,6 @@ describe('downloadTripPDF', () => {
|
||||
const iframe = getIframe()
|
||||
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
||||
expect(iframe!.srcdoc).toContain('ABC123')
|
||||
// Single-leg flight keeps its full-route subtitle.
|
||||
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
||||
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
||||
const iframe = getIframe()
|
||||
// One subtitle line per leg, each with its own flight number and segment route.
|
||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
|
||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
import { getFlightLegs } from '../../utils/flightLegs'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -216,30 +215,17 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
||||
let subtitleLines: string[] = []
|
||||
if (r.type === 'flight') {
|
||||
const legs = getFlightLegs(r)
|
||||
if (legs.length > 1) {
|
||||
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
||||
subtitleLines = legs.map(l =>
|
||||
[l.airline, l.flight_number,
|
||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
||||
.filter(Boolean).join(' · '))
|
||||
.filter(Boolean)
|
||||
} else {
|
||||
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
// Full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
@@ -252,7 +238,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
<span class="note-icon">${icon}</span>
|
||||
<div class="note-body">
|
||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')}
|
||||
${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>` : ''}
|
||||
</div>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { catMeta } from '../Budget/costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||
* category fields: when no expense is linked yet it offers a "create expense"
|
||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||
* once linked it shows the expense with edit / remove actions.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
||||
reservationId: number | null
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
onRemove: (item: BudgetItem) => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
if (linked) {
|
||||
const meta = catMeta(linked.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={15} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* A request from a booking modal to open the Costs expense editor — either to
|
||||
* edit the already-linked expense, or to create a new one prefilled from the
|
||||
* booking (the modal saves the booking first so `reservationId` is known).
|
||||
*/
|
||||
export interface BookingExpenseRequest {
|
||||
editItem?: BudgetItem
|
||||
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||
}
|
||||
@@ -18,16 +18,16 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
@@ -152,8 +152,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
@@ -381,8 +379,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||
useEffect(() => {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
@@ -390,7 +392,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
@@ -406,32 +408,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
|
||||
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
||||
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
||||
// the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const { morning: startHotel, evening: endHotel } =
|
||||
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
|
||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
|
||||
// legs connect even when the day starts or ends with a booking rather than a place.
|
||||
const wayPts: { lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
|
||||
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
|
||||
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
|
||||
}
|
||||
}
|
||||
const firstWay = wayPts[0]
|
||||
const lastWay = wayPts[wayPts.length - 1]
|
||||
const wantTop = !!(startHotel && firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay)
|
||||
|
||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
if (runs.length === 0) { setRouteLegs({}); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
legsAbortRef.current = controller
|
||||
@@ -445,27 +422,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
}
|
||||
}
|
||||
|
||||
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||
return r.legs[0]
|
||||
} catch { return undefined }
|
||||
}
|
||||
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||
if (wantTop) {
|
||||
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
|
||||
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||
}
|
||||
if (wantBottom) {
|
||||
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||
if (!controller.signal.aborted) setRouteLegs(map)
|
||||
})()
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
@@ -979,8 +938,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1128,8 +1085,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1472,9 +1427,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||
}}
|
||||
>
|
||||
{isSelected && hotelLegs.top && (
|
||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||
)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
@@ -2105,9 +2057,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isSelected && hotelLegs.bottom && (
|
||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||
)}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||
import { Car, Footprints } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
@@ -19,60 +19,3 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||
* morning departure from the hotel) and below the last place (the evening return),
|
||||
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
*/
|
||||
export function HotelRouteConnector({
|
||||
seg,
|
||||
profile,
|
||||
name,
|
||||
placement,
|
||||
}: {
|
||||
seg: RouteSegment
|
||||
profile: 'driving' | 'walking'
|
||||
name: string
|
||||
placement: 'top' | 'bottom'
|
||||
}) {
|
||||
const driving = profile === 'driving'
|
||||
const Icon = driving ? Car : Footprints
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
const hotelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
{travelRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{travelRow}
|
||||
{hotelRow}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,101 +253,6 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||
// it yields no usable place the modal must fall back to the reliable text search
|
||||
// instead of dead-ending on "Place search failed".
|
||||
|
||||
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Eiffel');
|
||||
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||
return screen.findByText('Paris, France');
|
||||
}
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
// Form is populated from the search fallback, and no error toast is shown.
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||
});
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
@@ -249,34 +249,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
// The details lookup is a fragile second hop — it can fail when the
|
||||
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||
// miss and fall back to the reliable text-search path the search button
|
||||
// uses (its results already carry coordinates), so dropdown items stay
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place) {
|
||||
handleSelectMapsResult(result.place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Place suggestion lookup failed:', err)
|
||||
console.error('Failed to fetch place details:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
|
||||
@@ -647,43 +647,5 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByText('Participants')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||
expect(scroll.style.overflowY).toBe('auto');
|
||||
expect(scroll.style.minHeight).toBe('0px');
|
||||
// flex must allow the region to shrink/grow within the capped card
|
||||
expect(scroll.style.flex).not.toBe('');
|
||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||
expect(descDiv).toBeTruthy();
|
||||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||
// Both description and notes containers must keep their natural height
|
||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||
expect(notes.length).toBe(2);
|
||||
for (const el of notes) {
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Avatar with open/closed ring + tag */}
|
||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -343,51 +343,56 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '99.99');
|
||||
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '50');
|
||||
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-027: 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(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||
)
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||
@@ -594,6 +599,22 @@ describe('ReservationModal', () => {
|
||||
expect(filePickerItem).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
// Budget section is visible
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
@@ -611,6 +632,31 @@ describe('ReservationModal', () => {
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
|
||||
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
||||
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
||||
await userEvent.click(budgetCategoryBtn);
|
||||
|
||||
// Click the "Transport" category option
|
||||
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Transport'));
|
||||
|
||||
// The select should now show "Transport"
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||
|
||||
@@ -11,10 +11,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
@@ -63,10 +60,9 @@ interface ReservationModalProps {
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -74,14 +70,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
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 [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
})
|
||||
@@ -127,12 +127,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
@@ -164,8 +167,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
return endFull <= startFull
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.()
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||
setIsSaving(true)
|
||||
@@ -182,6 +185,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
} 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
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const saveData: Record<string, any> & { title: string } = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||
@@ -194,6 +202,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id || null,
|
||||
@@ -215,25 +228,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// Open the Costs editor for the saved booking when the user asked to
|
||||
// create/edit its linked expense (gated on saved?.id).
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
@@ -611,14 +610,38 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -132,37 +132,34 @@ describe('TransportModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
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.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
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({ id: 42 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||
)
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -13,11 +13,8 @@ import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -108,6 +105,8 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
price: '',
|
||||
budget_category: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -125,20 +124,20 @@ interface TransportModalProps {
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: 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 deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
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 }>()
|
||||
// Set right before submitting when the user clicked "create/edit expense", so
|
||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
@@ -178,6 +177,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
@@ -228,8 +229,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@@ -288,6 +289,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
@@ -328,6 +334,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -338,14 +349,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// The user asked to create/edit the linked expense — open the Costs editor
|
||||
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
@@ -353,12 +356,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -715,14 +712,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||
const [writeEnabled, setWriteEnabled] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -31,7 +30,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
.then(d => {
|
||||
setUrl(d.url || '')
|
||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||
setWriteEnabled(!!d.writeEnabled)
|
||||
setConnected(!!d.connected)
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -48,7 +46,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||
setConnected(!!status.connected)
|
||||
setApiKey('')
|
||||
@@ -109,14 +107,6 @@ export default function AirTrailConnectionSection(): React.ReactElement {
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||
<button type="button" onClick={onToggle}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
|
||||
@@ -288,26 +288,4 @@ describe('TripFormModal', () => {
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
expect(dayInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await screen.findByText('Number of days is required');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7 as number | '',
|
||||
day_count: 7,
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -100,12 +100,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await onSave({
|
||||
@@ -114,7 +108,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
})
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
@@ -326,12 +320,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
}}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
||||
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
||||
{/* Floating toolbar */}
|
||||
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||
<button
|
||||
onClick={() => setCompanyMode(false)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
|
||||
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
||||
* for the dashboard widget) for the given base and caches per base in memory +
|
||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||
* currency C converts to base as `amount / rates[C]`.
|
||||
@@ -33,19 +33,14 @@ export function useExchangeRates(base: string) {
|
||||
if (cached) setRates(cached.rates)
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||
let cancelled = false
|
||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
||||
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
||||
.then(r => r.json())
|
||||
.then((d: Array<{ quote?: string; rate?: number }>) => {
|
||||
if (cancelled || !Array.isArray(d)) return
|
||||
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
||||
const rates: Record<string, number> = { [upper]: 1 }
|
||||
for (const r of d) {
|
||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
||||
}
|
||||
const entry = { rates, ts: Date.now() }
|
||||
.then((d: { rates?: Record<string, number> }) => {
|
||||
if (cancelled || !d?.rates) return
|
||||
const entry = { rates: d.rates, ts: Date.now() }
|
||||
mem.set(upper, entry)
|
||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||
setRates(rates)
|
||||
setRates(d.rates)
|
||||
})
|
||||
.catch(() => { /* offline → keep cached/identity */ })
|
||||
return () => { cancelled = true }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
@@ -54,6 +53,12 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
@@ -62,15 +67,12 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => {
|
||||
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||
return {
|
||||
kind: 'transport' as const,
|
||||
from,
|
||||
to,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
}
|
||||
}),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group located places into driving runs.
|
||||
|
||||
@@ -6,7 +6,6 @@ import CustomSelect from '../components/shared/CustomSelect'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||
import type { TranslationFn } from '../types'
|
||||
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
import { useAtlas } from './atlas/useAtlas'
|
||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
@@ -213,8 +212,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||
const cont = continentForCountry(confirmAction.code)
|
||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -262,8 +260,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
const cont = continentForCountry(countryCode)
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -342,12 +339,10 @@ export default function AtlasPage(): React.ReactElement {
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
const cont = continentForCountry(countryCode)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -20,11 +20,8 @@ beforeEach(() => {
|
||||
} as any);
|
||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||
server.use(
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => {
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
|
||||
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
|
||||
]);
|
||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@ import {
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -38,7 +36,6 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
|
||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
||||
if (!dateStr) return null
|
||||
const date = new Date(dateStr + 'T00:00:00Z')
|
||||
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
||||
return {
|
||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||
@@ -464,15 +461,9 @@ function CurrencyTool(): React.ReactElement {
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
||||
|
||||
const fetchRate = React.useCallback(() => {
|
||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
|
||||
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||
.then(r => r.json())
|
||||
.then((d: Array<{ quote: string; rate: number }>) => {
|
||||
if (!Array.isArray(d)) { setRates(null); return }
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 }
|
||||
for (const r of d) map[r.quote] = r.rate
|
||||
setRates(map)
|
||||
})
|
||||
.then(d => setRates(d.rates ?? null))
|
||||
.catch(() => setRates(null))
|
||||
}, [from])
|
||||
|
||||
@@ -605,7 +596,6 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format)
|
||||
return (
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
@@ -616,13 +606,10 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
) : (
|
||||
<div className="upc-list">
|
||||
{items.map(r => {
|
||||
// Read the date/time straight from the stored string parts. Going through
|
||||
// new Date(...).toISOString() reinterprets the naive local time as UTC and
|
||||
// can roll the displayed day forward/back in non-UTC timezones.
|
||||
const parsed = splitReservationDateTime(r.reservation_time)
|
||||
const datePart = parsed.date || r.day_date || null
|
||||
const dateStr = datePart ? splitDate(datePart, locale) : null
|
||||
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
|
||||
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
|
||||
const d = when ? new Date(when) : null
|
||||
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
|
||||
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
|
||||
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
||||
return (
|
||||
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
||||
|
||||
@@ -103,38 +103,6 @@ describe('LoginPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/auth/login', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
||||
it('shows a Register button to switch to registration mode', async () => {
|
||||
// Default appConfig has allow_registration: true, has_users: true
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||
import { useLogin } from './login/useLogin'
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
@@ -10,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
@@ -573,16 +572,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||
<span
|
||||
onClick={() => setRememberMe(!rememberMe)}
|
||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||
>
|
||||
{t('login.rememberMe')}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
|
||||
@@ -405,79 +405,4 @@ describe('SharedTripPage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-017: Multi-leg flight shows each leg in the Day Plan', () => {
|
||||
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null };
|
||||
const multiLegFlight = {
|
||||
id: 9, trip_id: 1, title: 'Flight', type: 'flight', status: 'confirmed',
|
||||
day_id: 101, end_day_id: 101,
|
||||
reservation_time: '2026-07-01T08:00:00', reservation_end_time: '2026-07-01T20:00:00',
|
||||
metadata: JSON.stringify({
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1', dep_day_id: 101, dep_time: '08:00', arr_day_id: 101, arr_time: '09:00' },
|
||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2', dep_day_id: 101, dep_time: '10:00', arr_day_id: 101, arr_time: '20:00' },
|
||||
],
|
||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
||||
}),
|
||||
};
|
||||
|
||||
function serveMultiLeg(token: string) {
|
||||
server.use(
|
||||
http.get('/api/shared/:token', ({ params }) => {
|
||||
if (params.token !== token) return;
|
||||
return HttpResponse.json({
|
||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
|
||||
days: [day],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
places: [],
|
||||
reservations: [multiLegFlight],
|
||||
accommodations: [],
|
||||
packing: [],
|
||||
budget: [],
|
||||
categories: [],
|
||||
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
|
||||
collab: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('renders each leg with its own route, not the overall start/end', async () => {
|
||||
serveMultiLeg('multileg-token');
|
||||
renderSharedTrip('multileg-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expand the day to reveal the timeline
|
||||
fireEvent.click(screen.getByText('Day One'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/FRA → BER/)).toBeInTheDocument();
|
||||
});
|
||||
// Second leg shows its OWN route + flight number (the bug showed the overall route here)
|
||||
expect(screen.getByText(/BER → HND/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||
// The overall start→end must NOT appear on any leg
|
||||
expect(screen.queryByText(/FRA → HND/)).toBeNull();
|
||||
});
|
||||
|
||||
it('lists each leg flight number in the Bookings tab', async () => {
|
||||
serveMultiLeg('multileg-bookings-token');
|
||||
renderSharedTrip('multileg-bookings-token');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /bookings/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/LH1/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { getFlightLegs } from '../utils/flightLegs'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
@@ -215,24 +214,16 @@ export default function SharedTripPage() {
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||
const endTime = splitReservationDateTime(r.reservation_end_time).time ?? ''
|
||||
let sub = ''
|
||||
if (r.type === 'flight') {
|
||||
if (r.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route/flight number.
|
||||
sub = [r.__leg.airline, r.__leg.flight_number, (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
||||
} else {
|
||||
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div key={`t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<TIcon size={12} color="#3b82f6" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''}</div>
|
||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
||||
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,11 +284,7 @@ export default function SharedTripPage() {
|
||||
{date && <span>{date}</span>}
|
||||
{time && <span>{time}</span>}
|
||||
{r.location && <span>{r.location}</span>}
|
||||
{r.type === 'flight'
|
||||
? getFlightLegs(r).map((leg, i) => (
|
||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
||||
))
|
||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||
{meta.train_number && <span>{meta.train_number}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1160,13 +1160,10 @@ describe('TripPlannerPage', () => {
|
||||
});
|
||||
|
||||
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
||||
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
|
||||
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
seedTripStore({ id: 42 });
|
||||
// Capture the update payload — tripActions is a snapshot of the store at mount.
|
||||
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
|
||||
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
|
||||
|
||||
renderPlannerPage(42);
|
||||
|
||||
@@ -1182,24 +1179,20 @@ describe('TripPlannerPage', () => {
|
||||
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
|
||||
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
||||
await act(async () => {
|
||||
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
||||
});
|
||||
|
||||
// Call onSave — now takes edit path (editingReservation is set)
|
||||
await act(async () => {
|
||||
await capturedReservationModalProps.current.onSave?.({
|
||||
name: 'Updated Booking',
|
||||
type: 'tour',
|
||||
type: 'restaurant',
|
||||
status: 'confirmed',
|
||||
});
|
||||
});
|
||||
|
||||
// The client must NOT send a day_id (no forcing to the selected day, no
|
||||
// stale value) — the server keeps/derives it from the booking's date.
|
||||
expect(updateReservationSpy).toHaveBeenCalled();
|
||||
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -25,9 +25,7 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import CostsPanel from '../components/Budget/CostsPanel'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
@@ -214,18 +212,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||
}
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div className="bg-surface" style={{
|
||||
@@ -650,8 +636,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onImport={() => setShowBookingImport(true)}
|
||||
bookingImportAvailable={bookingImportAvailable}
|
||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||
airTrailAvailable={airTrailAvailable}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
@@ -720,20 +704,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<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} onOpenExpense={openBookingExpense} />
|
||||
{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)} onOpenExpense={openBookingExpense} />}
|
||||
{bookingExpense && (
|
||||
<ExpenseModal
|
||||
tripId={tripId}
|
||||
base={costsBase}
|
||||
people={tripMembers}
|
||||
me={meId}
|
||||
editing={bookingExpense.editing}
|
||||
prefill={bookingExpense.prefill}
|
||||
onClose={() => setBookingExpense(null)}
|
||||
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
||||
/>
|
||||
)}
|
||||
<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} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -6,7 +6,6 @@ import apiClient, { mapsApi } from '../../api/client'
|
||||
import L from 'leaflet'
|
||||
import type { GeoJsonFeatureCollection } from '../../types'
|
||||
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||
import { continentForCountry } from '@trek/shared'
|
||||
|
||||
function useCountryNames(language: string): (code: string) => string {
|
||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||
@@ -341,10 +340,7 @@ export function useAtlas() {
|
||||
</div>
|
||||
</div>`
|
||||
layer.bindTooltip(tooltipHtml, {
|
||||
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
|
||||
// bounds centre, which for countries with overseas territories (e.g. France) lands
|
||||
// far out in the ocean instead of over the area being hovered.
|
||||
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
@@ -367,7 +363,7 @@ export function useAtlas() {
|
||||
country_layer_by_a2_ref.current[countryCode] = layer
|
||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -556,20 +552,6 @@ export function useAtlas() {
|
||||
} catch (e ) {
|
||||
console.error('Error fitting bounds', e)
|
||||
}
|
||||
|
||||
// Mirror the map-click behaviour so an already-visited country can be removed
|
||||
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
|
||||
// hit on the map, so search was the only way in — but it always opened the
|
||||
// "Mark / Bucket" dialog with no Remove option.
|
||||
const visited = data?.countries.find(c => c.code === country_code)
|
||||
if (visited) {
|
||||
if (visited.placeCount === 0 && visited.tripCount === 0) {
|
||||
handleUnmarkCountry(country_code)
|
||||
} else {
|
||||
loadCountryDetailRef.current(country_code)
|
||||
}
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||
}
|
||||
|
||||
@@ -583,12 +565,10 @@ export function useAtlas() {
|
||||
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
||||
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -599,12 +579,10 @@ export function useAtlas() {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === code)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const cont = continentForCountry(code)
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== code),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
||||
}
|
||||
})
|
||||
setVisitedRegions(prev => {
|
||||
|
||||
@@ -37,7 +37,6 @@ export function useLogin() {
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(false)
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
@@ -243,7 +242,7 @@ export function useLogin() {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||
setSavedLoginPassword(password)
|
||||
setPasswordChangeStep(true)
|
||||
@@ -259,7 +258,7 @@ export function useLogin() {
|
||||
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
const result = await login(email, password, rememberMe)
|
||||
const result = await login(email, password)
|
||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||
setMfaToken(result.mfa_token)
|
||||
setMfaStep(true)
|
||||
@@ -290,7 +289,7 @@ export function useLogin() {
|
||||
return {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
|
||||
@@ -568,12 +568,7 @@ export function useTripPlanner() {
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
// Don't force a day here. The old code pinned it to the (often empty)
|
||||
// selected day, which dropped the booking out of the Plan; preserving the
|
||||
// old day_id instead left it stale when the date changed. Omitting it lets
|
||||
// the server derive the day from the booking's date, or keep the current
|
||||
// one when there is no date.
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
setShowReservationModal(false)
|
||||
setEditingReservation(null)
|
||||
|
||||
@@ -39,8 +39,8 @@ interface AuthState {
|
||||
placesAutocompleteEnabled: boolean
|
||||
placesDetailsEnabled: boolean
|
||||
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
@@ -99,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
|
||||
login: async (email: string, password: string, rememberMe?: boolean) => {
|
||||
login: async (email: string, password: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
if (data.mfa_required && data.mfa_token) {
|
||||
set({ isLoading: false, error: null })
|
||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||
@@ -128,11 +128,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
@@ -34,38 +34,6 @@ describe('getSpanPhase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportRouteEndpoints', () => {
|
||||
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
|
||||
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
|
||||
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
|
||||
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
|
||||
|
||||
it('routes to the pickup only on the start day of a multi-day rental', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
|
||||
})
|
||||
|
||||
it('routes from the drop-off only on the end day', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
|
||||
})
|
||||
|
||||
it('adds no waypoints on the days in between (regression for #1210)', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
|
||||
})
|
||||
|
||||
it('uses both endpoints for a single-day transport', () => {
|
||||
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
|
||||
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
|
||||
from: { lat: 48.1, lng: 11.5 },
|
||||
to: { lat: 52.5, lng: 13.4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns nulls when the endpoints carry no coordinates', () => {
|
||||
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
|
||||
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
|
||||
@@ -29,33 +29,6 @@ export function getSpanPhase(
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
/**
|
||||
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
|
||||
* A car rental (or any reservation whose span covers several days) is only routed to on its
|
||||
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
|
||||
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
|
||||
* route to those points. Single-day transports contribute both endpoints.
|
||||
*/
|
||||
export function getTransportRouteEndpoints(
|
||||
r: any,
|
||||
dayId: number
|
||||
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
|
||||
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
switch (getSpanPhase(r, dayId)) {
|
||||
case 'start':
|
||||
return { from: ep('from'), to: null }
|
||||
case 'end':
|
||||
return { from: null, to: ep('to') }
|
||||
case 'middle':
|
||||
return { from: null, to: null }
|
||||
default:
|
||||
return { from: ep('from'), to: ep('to') }
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Day, Accommodation } from '../types'
|
||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from './dayOrder'
|
||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
|
||||
|
||||
const days = [
|
||||
{ id: 10, day_number: 1 },
|
||||
@@ -70,51 +70,4 @@ describe('getAccommodationAnchors', () => {
|
||||
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps morning/evening correct on a transfer day when the morning stay runs long (#887)', () => {
|
||||
const accs = [
|
||||
hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 }), // slept here, checks out later
|
||||
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
|
||||
]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
|
||||
start: { lat: 1, lng: 1 },
|
||||
end: { lat: 9, lng: 9 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDayBookendHotels', () => {
|
||||
it('returns nothing when the day has no accommodation', () => {
|
||||
expect(getDayBookendHotels(days[1], days, [])).toEqual({})
|
||||
})
|
||||
|
||||
it('bookends both ends with the single hotel on a normal stay day', () => {
|
||||
const h = hotel({ start_day_id: 10, end_day_id: 30 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [h])
|
||||
expect(morning).toBe(h)
|
||||
expect(evening).toBe(h)
|
||||
})
|
||||
|
||||
it('uses the checked-out hotel in the morning and the checked-in hotel in the evening on a transfer day', () => {
|
||||
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
|
||||
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [out, into])
|
||||
expect(morning).toBe(out)
|
||||
expect(evening).toBe(into)
|
||||
})
|
||||
|
||||
it('still picks the slept-in hotel for the morning when its stay does not end on the transfer day (#887)', () => {
|
||||
// The morning hotel runs long (checks out day 3) so it is not flagged as "checks out today";
|
||||
// the old "ends today" rule collapsed both bookends onto the arriving hotel.
|
||||
const stayed = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 })
|
||||
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [stayed, into])
|
||||
expect(morning).toBe(stayed)
|
||||
expect(evening).toBe(into)
|
||||
})
|
||||
|
||||
it('ignores accommodations without coordinates', () => {
|
||||
const h = hotel({ place_lat: null, place_lng: null })
|
||||
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,36 +3,6 @@ import type { Day, Accommodation, RouteAnchors } from '../types'
|
||||
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||
day.day_number ?? days.indexOf(day)
|
||||
|
||||
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
|
||||
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
|
||||
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
|
||||
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
|
||||
// not end exactly on the transfer day.
|
||||
export const getDayBookendHotels = (
|
||||
day: Day,
|
||||
days: Day[],
|
||||
accommodations: Accommodation[],
|
||||
): { morning?: Accommodation; evening?: Accommodation } => {
|
||||
const inRange = accommodations.filter(a =>
|
||||
a.place_lat != null && a.place_lng != null &&
|
||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||
)
|
||||
if (inRange.length === 0) return {}
|
||||
|
||||
const dayOrd = getDayOrder(day, days)
|
||||
const orderOf = (id: number) => {
|
||||
const d = days.find(x => x.id === id)
|
||||
return d ? getDayOrder(d, days) : dayOrd
|
||||
}
|
||||
const checkIn = inRange.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||
const sleptHere = inRange.find(a => orderOf(a.start_day_id) < dayOrd) // the hotel you woke up in
|
||||
|
||||
return {
|
||||
morning: sleptHere ?? checkIn ?? inRange[0],
|
||||
evening: checkIn ?? sleptHere ?? inRange[0],
|
||||
}
|
||||
}
|
||||
|
||||
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
|
||||
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
|
||||
// and into another — instead runs from the morning hotel to the evening one.
|
||||
@@ -41,12 +11,22 @@ export const getAccommodationAnchors = (
|
||||
days: Day[],
|
||||
accommodations: Accommodation[],
|
||||
): RouteAnchors => {
|
||||
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
|
||||
if (!morning || !evening) return {}
|
||||
return {
|
||||
start: { lat: morning.place_lat as number, lng: morning.place_lng as number },
|
||||
end: { lat: evening.place_lat as number, lng: evening.place_lng as number },
|
||||
const located = accommodations.filter(a =>
|
||||
a.place_lat != null && a.place_lng != null &&
|
||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||
)
|
||||
if (located.length === 0) return {}
|
||||
|
||||
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
|
||||
|
||||
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
|
||||
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||
if (checkOut && checkIn && checkOut !== checkIn) {
|
||||
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
|
||||
}
|
||||
|
||||
const hotel = toAnchor(located[0])
|
||||
return { start: hotel, end: hotel }
|
||||
}
|
||||
|
||||
export const isDayInAccommodationRange = (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/// <reference types="node" />
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
@@ -23,7 +23,6 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - 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
|
||||
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
||||
# - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
|
||||
- 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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 321 KiB After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 792 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 344 KiB |
Generated
+2342
-5575
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.22",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -25,7 +25,7 @@
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.3"
|
||||
"concurrently": "^9.2.1"
|
||||
},
|
||||
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
|
||||
"overrides": {
|
||||
@@ -33,9 +33,9 @@
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.62.0",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.1",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.1"
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.33.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ NODE_ENV=development # development = development mode; production = production m
|
||||
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
||||
# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h
|
||||
# SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login — longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
|
||||
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
||||
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||
@@ -35,8 +34,6 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
||||
|
||||
DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
|
||||
|
||||
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
|
||||
|
||||
Binary file not shown.
+13
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.1.0",
|
||||
"version": "3.0.22",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -21,12 +21,13 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trek/shared": "*",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -46,7 +47,6 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
@@ -67,9 +67,16 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -87,19 +94,11 @@
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-istanbul": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^4.1.9"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,37 +151,18 @@ function normalizeAdm0Feature(f) {
|
||||
|
||||
function normalizeAdm1(geo, a3, countryName) {
|
||||
if (!geo?.features) return []
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// Ensure every region in a country ends up with a distinct iso_3166_2 — the Atlas
|
||||
// marks/unmarks regions by this code, so duplicates make one mark light up the whole
|
||||
// country.
|
||||
const used = new Set()
|
||||
const uniq = (base) => {
|
||||
let code = base, n = 2
|
||||
while (used.has(code)) code = `${base}-${n++}`
|
||||
used.add(code)
|
||||
return code
|
||||
}
|
||||
return geo.features.map(f => {
|
||||
const name = f.properties?.shapeName || ''
|
||||
const geometry = quantizeGeometry(f.geometry, ADM1_DECIMALS)
|
||||
if (!geometry) return null
|
||||
// shapeISO is a real ISO 3166-2 code for most features, but geoBoundaries sometimes
|
||||
// fills it with the bare country code instead of a subdivision code — e.g. every
|
||||
// Spanish region gets "ESP", every Chinese "CHN" (also CL/OM). Keep it only when it
|
||||
// is a real `XX-…` subdivision code and not already taken; otherwise synthesize a
|
||||
// stable, unique-per-country id from the region name so each region is independently
|
||||
// markable.
|
||||
const raw = f.properties?.shapeISO || ''
|
||||
let code
|
||||
if (/^[A-Za-z]{2}-[A-Za-z0-9]+$/.test(raw) && !used.has(raw)) {
|
||||
code = raw
|
||||
used.add(code)
|
||||
} else if (a2) {
|
||||
code = uniq(`${a2}-${name.replace(/[^A-Za-z0-9]/g, '').toUpperCase() || 'RGN'}`)
|
||||
} else {
|
||||
code = raw
|
||||
}
|
||||
const a2 = A3_TO_A2[a3] || null
|
||||
// shapeISO is a real ISO 3166-2 code for ~90% of features; geoBoundaries leaves the
|
||||
// rest blank or uses an `XX_YYY` placeholder. Keep real/placeholder codes as-is
|
||||
// (stable per polygon → manual mark/unmark works, real ones match Nominatim). For
|
||||
// blank codes, synthesize a stable id mirroring the server's geocode fallback so
|
||||
// every region is still markable.
|
||||
let code = f.properties?.shapeISO || ''
|
||||
if (!code && a2) code = `${a2}-${name.replace(/[^A-Za-z0-9]/g, '').substring(0, 3).toUpperCase()}`
|
||||
return {
|
||||
type: 'Feature',
|
||||
// Property names the Atlas region layer + server getRegionGeo already read.
|
||||
|
||||
@@ -136,21 +136,3 @@ export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATI
|
||||
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
|
||||
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
|
||||
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
|
||||
|
||||
// SESSION_DURATION_REMEMBER is the session length used when the user ticks
|
||||
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
|
||||
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
|
||||
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
|
||||
// format and fallback behavior as SESSION_DURATION.
|
||||
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||
if (parsedRememberMs == null) {
|
||||
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||
}
|
||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
|
||||
|
||||
+282
-847
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,6 @@ function seedAdminAccount(db: Database.Database): void {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
if (userCount > 0) return;
|
||||
|
||||
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
|
||||
|
||||
if (isOidcOnlyConfigured()) {
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════╗');
|
||||
|
||||
@@ -96,7 +96,7 @@ export function applyGlobalMiddleware(
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||
],
|
||||
|
||||
@@ -87,7 +87,7 @@ export class AuthPublicController {
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true, mfa_token: result.mfa_token };
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class AuthPublicController {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { User } from '../../types';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// Cookie
|
||||
setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); }
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
|
||||
|
||||
// Reset-email delivery (canonical app URL, never request headers)
|
||||
|
||||
@@ -94,31 +94,6 @@ export class BudgetController {
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Put('settlements/:settlementId')
|
||||
updateSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('settlementId') settlementId: string,
|
||||
@Body() body: { from_user_id?: number; to_user_id?: number; amount?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (body.from_user_id == null || body.to_user_id == null || body.amount == null) {
|
||||
throw new HttpException({ error: 'from_user_id, to_user_id and amount are required' }, 400);
|
||||
}
|
||||
const settlement = this.budget.updateSettlement(settlementId, tripId, {
|
||||
from_user_id: body.from_user_id,
|
||||
to_user_id: body.to_user_id,
|
||||
amount: body.amount,
|
||||
});
|
||||
if (!settlement) {
|
||||
throw new HttpException({ error: 'Settlement not found' }, 404);
|
||||
}
|
||||
this.budget.broadcast(tripId, 'budget:settlement-updated', { settlement }, socketId);
|
||||
return { settlement };
|
||||
}
|
||||
|
||||
@Delete('settlements/:settlementId')
|
||||
deleteSettlement(
|
||||
@CurrentUser() user: User,
|
||||
@@ -139,7 +114,7 @@ export class BudgetController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
|
||||
@@ -73,10 +73,6 @@ export class BudgetService {
|
||||
return svc.createSettlement(tripId, data, userId);
|
||||
}
|
||||
|
||||
updateSettlement(id: string, tripId: string, data: { from_user_id: number; to_user_id: number; amount: number }) {
|
||||
return svc.updateSettlement(id, tripId, data);
|
||||
}
|
||||
|
||||
deleteSettlement(id: string, tripId: string): boolean {
|
||||
return svc.deleteSettlement(id, tripId);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export class AirtrailController {
|
||||
body.url,
|
||||
body.apiKey,
|
||||
!!body.allowInsecureTls,
|
||||
!!body.writeEnabled,
|
||||
getClientIp(req),
|
||||
);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
@@ -78,51 +77,30 @@ export class ReservationsService {
|
||||
|
||||
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
|
||||
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
// When the booking type changes, keep a linked expense's category in sync —
|
||||
// but only if it still carries the auto-derived category (so a manual pick in
|
||||
// the Costs editor is preserved). Runs regardless of create_budget_entry.
|
||||
if (type && currentType && type !== currentType) {
|
||||
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||
if (linked) {
|
||||
const oldCat = typeToCostCategory(currentType);
|
||||
const newCat = typeToCostCategory(type);
|
||||
if (oldCat !== newCat && linked.category === oldCat) {
|
||||
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No budget entry on the payload — the booking edit isn't touching its linked
|
||||
// expense, so leave any linked item alone. Expenses are managed from the
|
||||
// booking's Costs section / the Costs tab, not by re-saving the booking.
|
||||
if (!entry) return;
|
||||
|
||||
if (!(Number(entry.total_price) > 0)) {
|
||||
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||
if (!entry || !entry.total_price) {
|
||||
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', { itemId: linked.id }, socketId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
if (entry && Number(entry.total_price) > 0) {
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ function airportCode(a: AirtrailAirport | null): string | null {
|
||||
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
|
||||
* them to a single code (ICAO preferred, matching AirTrail's save shape).
|
||||
*/
|
||||
export function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
|
||||
return e?.icao || e?.iata || null;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservatio
|
||||
if (aircraftCode) metadata.aircraft = aircraftCode;
|
||||
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
|
||||
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
|
||||
if (seat?.seatNumber) metadata.seat = seat.seatNumber;
|
||||
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
|
||||
|
||||
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
|
||||
// makes the clearest title; fall back to the route.
|
||||
|
||||
@@ -12,25 +12,14 @@ interface UserConnRow {
|
||||
airtrail_url?: string | null;
|
||||
airtrail_api_key?: string | null;
|
||||
airtrail_allow_insecure_tls?: number | null;
|
||||
airtrail_write_enabled?: number | null;
|
||||
}
|
||||
|
||||
function readRow(userId: number): UserConnRow | undefined {
|
||||
return db
|
||||
.prepare(
|
||||
'SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?',
|
||||
)
|
||||
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
|
||||
.get(userId) as UserConnRow | undefined;
|
||||
}
|
||||
|
||||
/** Has this user opted in to TREK writing their flight edits back to AirTrail? (#1240) */
|
||||
export function isAirtrailWriteEnabled(userId: number): boolean {
|
||||
const row = db.prepare('SELECT airtrail_write_enabled FROM users WHERE id = ?').get(userId) as
|
||||
| { airtrail_write_enabled?: number | null }
|
||||
| undefined;
|
||||
return !!row?.airtrail_write_enabled;
|
||||
}
|
||||
|
||||
/** Decrypted creds for outbound calls, or null when the user has no connection. */
|
||||
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
|
||||
const row = readRow(userId);
|
||||
@@ -51,7 +40,6 @@ export function getConnectionSettings(userId: number) {
|
||||
url: row?.airtrail_url || '',
|
||||
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
|
||||
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
|
||||
writeEnabled: !!row?.airtrail_write_enabled,
|
||||
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
|
||||
};
|
||||
}
|
||||
@@ -61,7 +49,6 @@ export async function saveSettings(
|
||||
url: string | undefined,
|
||||
apiKey: string | undefined,
|
||||
allowInsecureTls: boolean,
|
||||
writeEnabled: boolean,
|
||||
clientIp: string | null,
|
||||
): Promise<{ success: boolean; warning?: string; error?: string }> {
|
||||
const trimmedUrl = (url || '').trim();
|
||||
@@ -94,12 +81,12 @@ export async function saveSettings(
|
||||
|
||||
if (newKey !== undefined) {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
|
||||
} else {
|
||||
db.prepare(
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, airtrail_write_enabled = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, writeEnabled ? 1 : 0, userId);
|
||||
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
|
||||
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
|
||||
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
|
||||
if (!trimmedUrl) {
|
||||
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { db } from '../../db/database';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
@@ -13,8 +14,7 @@ import {
|
||||
listFlights,
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, entityCode, mapFlightToReservation } from './airtrailMapper';
|
||||
import { getAirtrailCredentials, isAirtrailWriteEnabled } from './airtrailService';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
export function syncGloballyEnabled(): boolean {
|
||||
@@ -59,7 +59,7 @@ async function syncOwner(uid: number): Promise<number> {
|
||||
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map(flights.map((f) => [String(f.id), f]));
|
||||
const byId = new Map(flights.map(f => [String(f.id), f]));
|
||||
|
||||
const linked = db
|
||||
.prepare(
|
||||
@@ -144,25 +144,16 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
|
||||
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the POST /flight/save body. AirTrail's save fully overwrites the flight,
|
||||
* so we start from the flight as AirTrail currently has it (`existing`, the raw
|
||||
* GET object) and overwrite ONLY the fields TREK manages. Everything else —
|
||||
* terminal, gate, scheduled/actual times, customFields, track, and any field
|
||||
* AirTrail may add later — passes through untouched. We deliberately do NOT model
|
||||
* those fields; spreading the raw object keeps us decoupled from AirTrail's schema
|
||||
* (#1240).
|
||||
*/
|
||||
export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any>;
|
||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any> = {};
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find((e) => e.role === 'from');
|
||||
const toEp = endpoints.find((e) => e.role === 'to');
|
||||
const fromEp = endpoints.find(e => e.role === 'from');
|
||||
const toEp = endpoints.find(e => e.role === 'to');
|
||||
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
|
||||
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
|
||||
if (!fromCode || !toCode) return null;
|
||||
@@ -173,7 +164,7 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
|
||||
|
||||
// Preserve the existing seat manifest (an update replaces all seats); fall back
|
||||
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
|
||||
const seats = (existing.seats ?? []).map((s) => ({
|
||||
const seats = (existing.seats ?? []).map(s => ({
|
||||
userId: s.userId,
|
||||
guestName: s.guestName,
|
||||
seat: s.seat,
|
||||
@@ -188,18 +179,11 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
|
||||
// a userId), leaving any co-passenger seats untouched.
|
||||
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
|
||||
if (seatNumber) {
|
||||
const ownSeat = seats.find((s) => s.userId) ?? seats[0];
|
||||
const ownSeat = seats.find(s => s.userId) ?? seats[0];
|
||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||
}
|
||||
|
||||
// Spread the existing flight first to preserve every AirTrail-owned field, then
|
||||
// overwrite only what TREK manages. `from`/`to`/`airline`/`aircraft` come back
|
||||
// from GET as objects but the save shape wants codes — those are exactly the
|
||||
// keys we override, so the spread never ships an object where a code is wanted.
|
||||
return {
|
||||
// Cast so the spread carries through the AirTrail-owned keys we deliberately
|
||||
// don't model (terminal, gate, scheduled/actual times, customFields, track, …).
|
||||
...(existing as unknown as Record<string, unknown>),
|
||||
id: Number(reservation.external_id),
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
@@ -207,18 +191,14 @@ export function buildSavePayload(reservation: any, existing: AirtrailFlightRaw):
|
||||
departureTime: dep.time,
|
||||
arrival: arr.date,
|
||||
arrivalTime: arr.time,
|
||||
// These are AirTrail-owned details TREK doesn't surface in its edit UI — a TREK
|
||||
// edit can leave them out of `metadata`. Preserve AirTrail's current value when
|
||||
// TREK has none rather than nulling it out (#1240). entityCode mirrors the
|
||||
// import/hash code-selection so a writeback stays a no-op for the hash.
|
||||
airline: meta.airline ?? entityCode(existing.airline) ?? null,
|
||||
flightNumber: meta.flight_number ?? existing.flightNumber ?? null,
|
||||
aircraft: meta.aircraft ?? entityCode(existing.aircraft) ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? existing.aircraftReg ?? null,
|
||||
flightReason: meta.flight_reason ?? existing.flightReason ?? null,
|
||||
note: reservation.notes ?? existing.note ?? null,
|
||||
airline: meta.airline ?? null,
|
||||
flightNumber: meta.flight_number ?? null,
|
||||
aircraft: meta.aircraft ?? null,
|
||||
aircraftReg: meta.aircraft_reg ?? null,
|
||||
flightReason: meta.flight_reason ?? null,
|
||||
note: reservation.notes ?? null,
|
||||
seats,
|
||||
} as AirtrailSavePayload;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,12 +219,9 @@ export async function pushReservationToAirtrail(reservationId: number, tripId: n
|
||||
| undefined;
|
||||
if (!row || !row.sync_enabled) return;
|
||||
|
||||
// AirTrail is read-only by default (#1240). Only push when the flight's owner has
|
||||
// explicitly opted in. A no-op skip (not a detach): the link stays active so the
|
||||
// inbound, AirTrail-wins pull keeps the reservation up to date.
|
||||
if (!row.external_owner_user_id || !isAirtrailWriteEnabled(row.external_owner_user_id)) return;
|
||||
|
||||
const creds: AirtrailCreds | null = getAirtrailCredentials(row.external_owner_user_id);
|
||||
const creds: AirtrailCreds | null = row.external_owner_user_id
|
||||
? getAirtrailCredentials(row.external_owner_user_id)
|
||||
: null;
|
||||
if (!creds) {
|
||||
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
|
||||
return;
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { db } from '../db/database';
|
||||
import { Trip, Place } from '../types';
|
||||
import { CONTINENT_MAP } from '@trek/shared';
|
||||
|
||||
// ── Bundled boundary GeoJSON (admin-0 countries + admin-1 regions) ─────────
|
||||
//
|
||||
@@ -169,6 +168,30 @@ export const NAME_TO_CODE: Record<string, string> = {
|
||||
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
|
||||
};
|
||||
|
||||
export const CONTINENT_MAP: Record<string, string> = {
|
||||
AF:'Asia',AL:'Europe',DZ:'Africa',AD:'Europe',AO:'Africa',AR:'South America',AM:'Asia',AU:'Oceania',AT:'Europe',AZ:'Asia',
|
||||
BA:'Europe',BD:'Asia',BF:'Africa',BH:'Asia',BI:'Africa',BJ:'Africa',BN:'Asia',BO:'South America',
|
||||
BR:'South America',BE:'Europe',BG:'Europe',BW:'Africa',
|
||||
CA:'North America',CD:'Africa',CG:'Africa',CI:'Africa',CL:'South America',CM:'Africa',CN:'Asia',CO:'South America',
|
||||
CR:'North America',CU:'North America',CV:'Africa',CY:'Europe',HR:'Europe',CZ:'Europe',
|
||||
DJ:'Africa',DK:'Europe',DO:'North America',EC:'South America',EG:'Africa',EE:'Europe',ER:'Africa',ET:'Africa',
|
||||
FI:'Europe',FR:'Europe',DE:'Europe',GE:'Asia',GH:'Africa',GN:'Africa',GR:'Europe',GT:'North America',
|
||||
HN:'North America',HT:'North America',HU:'Europe',IS:'Europe',IN:'Asia',ID:'Asia',IR:'Asia',IQ:'Asia',
|
||||
IE:'Europe',IL:'Asia',IT:'Europe',JM:'North America',JO:'Asia',JP:'Asia',KE:'Africa',KG:'Asia',KH:'Asia',
|
||||
KR:'Asia',KW:'Asia',KZ:'Asia',LA:'Asia',LB:'Asia',LK:'Asia',LV:'Europe',LT:'Europe',LU:'Europe',LY:'Africa',
|
||||
MA:'Africa',MD:'Europe',ME:'Europe',MG:'Africa',MK:'Europe',ML:'Africa',MM:'Asia',MN:'Asia',MR:'Africa',
|
||||
MT:'Europe',MU:'Africa',MV:'Asia',MW:'Africa',MY:'Asia',MX:'North America',MZ:'Africa',
|
||||
NA:'Africa',NE:'Africa',NI:'North America',NL:'Europe',NP:'Asia',NZ:'Oceania',NO:'Europe',OM:'Asia',
|
||||
PA:'North America',PG:'Oceania',PK:'Asia',PE:'South America',PH:'Asia',PL:'Europe',PS:'Asia',
|
||||
PT:'Europe',PY:'South America',QA:'Asia',RO:'Europe',RU:'Europe',RW:'Africa',SA:'Asia',SC:'Africa',
|
||||
SD:'Africa',SG:'Asia',SI:'Europe',SK:'Europe',SN:'Africa',SO:'Africa',RS:'Europe',SV:'North America',
|
||||
SY:'Asia',TG:'Africa',TJ:'Asia',TM:'Asia',TN:'Africa',TT:'North America',TW:'Asia',TZ:'Africa',
|
||||
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
|
||||
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
|
||||
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
|
||||
HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
|
||||
};
|
||||
|
||||
// ── Geocoding helpers ───────────────────────────────────────────────────────
|
||||
|
||||
let lastNominatimCall = 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||
import { getAllPermissions } from './permissions';
|
||||
@@ -181,17 +181,14 @@ export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }) {
|
||||
const pv = typeof user.password_version === 'number'
|
||||
? user.password_version
|
||||
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
|
||||
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
|
||||
// the cookie service decides session-vs-persistent off the same flag.
|
||||
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
|
||||
return jwt.sign(
|
||||
{ id: user.id, pv },
|
||||
JWT_SECRET,
|
||||
{ expiresIn, algorithm: 'HS256' }
|
||||
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,7 +443,6 @@ export function registerUser(body: {
|
||||
export function loginUser(body: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
@@ -454,7 +450,6 @@ export function loginUser(body: {
|
||||
user?: Record<string, unknown>;
|
||||
mfa_required?: boolean;
|
||||
mfa_token?: string;
|
||||
remember?: boolean;
|
||||
auditUserId?: number | null;
|
||||
auditAction?: string;
|
||||
auditDetails?: Record<string, unknown>;
|
||||
@@ -463,8 +458,7 @@ export function loginUser(body: {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
|
||||
const { email, password, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
const { email, password } = body;
|
||||
if (!email || !password) {
|
||||
return { error: 'Email and password are required', status: 400 };
|
||||
}
|
||||
@@ -506,13 +500,12 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user, remember);
|
||||
const token = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
auditAction: 'user.login',
|
||||
auditDetails: { email },
|
||||
@@ -1073,17 +1066,14 @@ export function disableMfa(
|
||||
export function verifyMfaLogin(body: {
|
||||
mfa_token?: string;
|
||||
code?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
remember?: boolean;
|
||||
auditUserId?: number;
|
||||
} {
|
||||
const { mfa_token, code, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
const { mfa_token, code } = body;
|
||||
if (!mfa_token || !code) {
|
||||
return { error: 'Verification token and code are required', status: 400 };
|
||||
}
|
||||
@@ -1114,12 +1104,11 @@ export function verifyMfaLogin(body: {
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user, remember);
|
||||
const sessionToken = generateToken(user);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
token: sessionToken,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -15,21 +15,7 @@ const dataDir = path.join(__dirname, '../../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
|
||||
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
|
||||
// past that. Invalid values warn and fall back to the default.
|
||||
const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500;
|
||||
const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim();
|
||||
let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB;
|
||||
if (rawBackupUploadLimit) {
|
||||
const parsed = Number(rawBackupUploadLimit);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
backupUploadLimitMb = parsed;
|
||||
} else {
|
||||
console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`);
|
||||
}
|
||||
}
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
|
||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
@@ -169,17 +155,6 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Bundle the at-rest encryption key so the backup is self-contained: the
|
||||
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
|
||||
// a restore onto a different install would otherwise be unable to decrypt
|
||||
// them. NOTE: this makes the backup file as sensitive as the key itself —
|
||||
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
|
||||
// env, since in that case the file is not the source of truth.
|
||||
const encKeyPath = path.join(dataDir, '.encryption_key');
|
||||
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
|
||||
archive.file(encKeyPath, { name: '.encryption_key' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
@@ -277,16 +252,6 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Restore the bundled at-rest encryption key (if the archive carries one)
|
||||
// so the restored DB's encrypted secrets can be decrypted. Only the file
|
||||
// is swapped here; the in-memory key was read at startup, so a restart is
|
||||
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
|
||||
// still overrides the file).
|
||||
const extractedEncKey = path.join(extractDir, '.encryption_key');
|
||||
if (fs.existsSync(extractedEncKey)) {
|
||||
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
|
||||
}
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
@@ -297,12 +262,7 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
|
||||
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
|
||||
// cpSync(dereference:false) would otherwise try to overwrite the symlink
|
||||
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
|
||||
@@ -105,7 +105,6 @@ export function createBudgetItem(
|
||||
currency?: string | null; exchange_rate?: number;
|
||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||
reservation_id?: number | null;
|
||||
},
|
||||
) {
|
||||
const maxOrder = db.prepare(
|
||||
@@ -129,7 +128,7 @@ export function createBudgetItem(
|
||||
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(
|
||||
tripId,
|
||||
cat,
|
||||
@@ -142,7 +141,6 @@ export function createBudgetItem(
|
||||
data.note || null,
|
||||
sortOrder,
|
||||
data.expense_date || null,
|
||||
data.reservation_id != null ? data.reservation_id : null,
|
||||
);
|
||||
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
@@ -210,15 +208,7 @@ export function updateBudgetItem(
|
||||
);
|
||||
|
||||
// Optional inline payer/member replacement (the edit modal saves all at once).
|
||||
if (data.payers !== undefined) {
|
||||
writeItemPayers(id, data.payers);
|
||||
// writeItemPayers derives total_price from the payer sum (0 for no payers).
|
||||
// A "recorded total, nobody assigned" expense clears payers but still carries
|
||||
// an explicit total_price — re-apply it so it isn't clobbered to 0.
|
||||
if (data.payers.length === 0 && data.total_price !== undefined) {
|
||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
|
||||
}
|
||||
}
|
||||
if (data.payers !== undefined) writeItemPayers(id, data.payers);
|
||||
if (data.member_ids !== undefined) {
|
||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
||||
@@ -385,18 +375,11 @@ export function calculateSettlement(
|
||||
}
|
||||
|
||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears. A transfer
|
||||
// counts even when neither user has an expense-derived balance yet — a manual
|
||||
// payment, or one left behind after its expense was deleted, then correctly
|
||||
// surfaces as an amount still to square up instead of silently vanishing.
|
||||
// the receiver's credit shrinks, so the corresponding flow disappears.
|
||||
const settlements = listSettlements(tripId);
|
||||
const ensureSettled = (id: number, username: string | undefined, avatar_url: string | null | undefined) => {
|
||||
if (!balances[id]) balances[id] = { user_id: id, username: username || '', avatar_url: avatar_url ?? null, balance: 0 };
|
||||
return balances[id];
|
||||
};
|
||||
for (const s of settlements) {
|
||||
ensureSettled(s.from_user_id, s.from_username, s.from_avatar_url).balance += s.amount;
|
||||
ensureSettled(s.to_user_id, s.to_username, s.to_avatar_url).balance -= s.amount;
|
||||
if (balances[s.from_user_id]) balances[s.from_user_id].balance += s.amount;
|
||||
if (balances[s.to_user_id]) balances[s.to_user_id].balance -= s.amount;
|
||||
}
|
||||
|
||||
// Calculate optimized payment flows (greedy algorithm)
|
||||
@@ -468,19 +451,6 @@ export function createSettlement(
|
||||
return listSettlements(tripId).find(s => s.id === Number(result.lastInsertRowid)) || null;
|
||||
}
|
||||
|
||||
export function updateSettlement(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
data: { from_user_id: number; to_user_id: number; amount: number },
|
||||
) {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return null;
|
||||
db.prepare(
|
||||
'UPDATE budget_settlements SET from_user_id = ?, to_user_id = ?, amount = ? WHERE id = ?'
|
||||
).run(data.from_user_id, data.to_user_id, Math.round(data.amount * 100) / 100, id);
|
||||
return listSettlements(tripId).find(s => s.id === Number(id)) || null;
|
||||
}
|
||||
|
||||
export function deleteSettlement(id: string | number, tripId: string | number): boolean {
|
||||
const row = db.prepare('SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||
if (!row) return false;
|
||||
|
||||
@@ -229,17 +229,12 @@ export function getPollWithVotes(pollId: number | bigint | string) {
|
||||
WHERE v.poll_id = ?
|
||||
`).all(pollId) as PollVoteRow[];
|
||||
|
||||
const formattedOptions = options.map((label: string | { label: string }, idx: number) => {
|
||||
const text = typeof label === 'string' ? label : label.label || label;
|
||||
return {
|
||||
// The client renders `opt.text`; keep `label` too for any other consumer.
|
||||
text,
|
||||
label: text,
|
||||
voters: votes
|
||||
.filter(v => v.option_index === idx)
|
||||
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
||||
};
|
||||
});
|
||||
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
|
||||
label: typeof label === 'string' ? label : label.label || label,
|
||||
voters: votes
|
||||
.filter(v => v.option_index === idx)
|
||||
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
||||
}));
|
||||
|
||||
return {
|
||||
...poll,
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
|
||||
import { SESSION_DURATION_MS } from '../config';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
/**
|
||||
* Controls the cookie lifetime for a login:
|
||||
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
|
||||
* default, used by register/demo and anything that doesn't opt in).
|
||||
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
|
||||
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
|
||||
*/
|
||||
export type RememberOption = boolean | undefined;
|
||||
|
||||
/**
|
||||
* Decide whether the session cookie should carry the `Secure` flag.
|
||||
*
|
||||
@@ -27,35 +18,27 @@ export type RememberOption = boolean | undefined;
|
||||
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||
return buildOptions(clear, false, remember);
|
||||
return buildOptions(clear, false);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure, remember);
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
}
|
||||
|
||||
function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
|
||||
// false → session cookie (omit maxAge); true → the longer "remember me"
|
||||
// window; undefined → the historical default. Each maxAge matches the JWT exp.
|
||||
if (remember === false) return {};
|
||||
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
|
||||
return { maxAge: SESSION_DURATION_MS };
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
|
||||
function buildOptions(clear: boolean, secure: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...(clear ? {} : resolveMaxAge(remember)),
|
||||
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Live exchange rates for the Costs/Budget money conversion.
|
||||
*
|
||||
* Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the
|
||||
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
|
||||
* dashboard widget) and caches per base currency in-memory for a few hours so a
|
||||
* settlement request never hammers the upstream. Rates are "units of X per 1
|
||||
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
||||
@@ -17,17 +17,10 @@ const inflight = new Map<string, Promise<Record<string, number> | null>>();
|
||||
|
||||
async function fetchRates(base: string): Promise<Record<string, number> | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`);
|
||||
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
|
||||
if (!res.ok) return null;
|
||||
// Frankfurter returns an array of { date, base, quote, rate } and omits the
|
||||
// base's own self-rate, so seed the map with `base = 1` then index by quote.
|
||||
const data = (await res.json()) as Array<{ quote?: string; rate?: number }>;
|
||||
if (!Array.isArray(data)) return null;
|
||||
const rates: Record<string, number> = { [base.toUpperCase()]: 1 };
|
||||
for (const r of data) {
|
||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate;
|
||||
}
|
||||
return Object.keys(rates).length > 1 ? rates : null;
|
||||
const data = (await res.json()) as { rates?: Record<string, number> };
|
||||
return data.rates && typeof data.rates === 'object' ? data.rates : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -417,10 +417,8 @@ export function findOrCreateUser(
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hash = bcrypt.hashSync(randomPass, 10);
|
||||
|
||||
// Username: sanitize and avoid collisions. Keep dots — they are valid in
|
||||
// usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common
|
||||
// in OIDC name claims like "first.last".
|
||||
let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user';
|
||||
// Username: sanitize and avoid collisions
|
||||
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
|
||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { db } from '../db/database';
|
||||
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
import { db } from '../db/database';
|
||||
|
||||
// Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches
|
||||
// the real uploads tree.
|
||||
@@ -27,9 +26,7 @@ const knownOnDisk = new Set<string>();
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
} catch { /* already exists */ }
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
@@ -49,9 +46,9 @@ interface CachedPhoto {
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db
|
||||
.prepare('SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL')
|
||||
.get(placeId) as { attribution: string | null } | undefined;
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -71,9 +68,9 @@ export function get(placeId: string): CachedPhoto | null {
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db
|
||||
.prepare('SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL')
|
||||
.get(placeId) as { error_at: number } | undefined;
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
@@ -82,7 +79,7 @@ export function getErrored(placeId: string): boolean {
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
@@ -112,28 +109,21 @@ export async function put(placeId: string, bytes: Buffer, attribution: string |
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(
|
||||
placeId: string,
|
||||
): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(
|
||||
placeId: string,
|
||||
promise: Promise<{ filePath: string; attribution: string | null } | null>,
|
||||
): void {
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise
|
||||
.finally(() => inFlight.delete(placeId))
|
||||
.catch(() => {
|
||||
/* awaiter logs; this .catch only prevents unhandledRejection */
|
||||
});
|
||||
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
@@ -148,18 +138,14 @@ export function serveFilePath(placeId: string): string | null {
|
||||
// Google place_id (the dedup key) or by the stable proxy URL stored in image_url
|
||||
// (covers coords: pseudo-ids, which never have a google_place_id).
|
||||
function isReferenced(placeId: string): boolean {
|
||||
const row = db
|
||||
.prepare('SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1')
|
||||
.get(placeId, proxyUrl(placeId));
|
||||
const row = db.prepare(
|
||||
'SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1'
|
||||
).get(placeId, proxyUrl(placeId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function deleteEntry(placeId: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath(placeId));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try { fs.unlinkSync(filePath(placeId)); } catch { /* already gone */ }
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
knownOnDisk.delete(placeId);
|
||||
}
|
||||
@@ -189,20 +175,11 @@ export function sweepOrphans(): number {
|
||||
|
||||
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
|
||||
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(GOOGLE_PHOTO_DIR);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
let entries: string[] = [];
|
||||
try { entries = fs.readdirSync(GOOGLE_PHOTO_DIR); } catch { entries = []; }
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
|
||||
try {
|
||||
fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry));
|
||||
removed++;
|
||||
} catch {
|
||||
/* race */
|
||||
}
|
||||
try { fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry)); removed++; } catch { /* race */ }
|
||||
}
|
||||
|
||||
return removed;
|
||||
|
||||
@@ -364,19 +364,12 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
// otherwise derive from the (possibly updated) reservation_time so the
|
||||
// planner renders the booking on the correct day.
|
||||
let nextDayId: number | null;
|
||||
if (day_id != null) {
|
||||
// Explicit day from the client (e.g. moved on the planner).
|
||||
nextDayId = day_id;
|
||||
} else if (resolvedType !== 'hotel' && nextReservationTime) {
|
||||
// No day set but we have a date — pin it to the matching day so the booking
|
||||
// still shows in the Plan (covers bookings saved without a selected day, and
|
||||
// the case where an earlier edit cleared day_id).
|
||||
if (day_id !== undefined) {
|
||||
nextDayId = day_id || null;
|
||||
} else if (reservation_time !== undefined && resolvedType !== 'hotel') {
|
||||
nextDayId = resolveDayIdFromTime(tripId, nextReservationTime);
|
||||
} else if (day_id === undefined) {
|
||||
// Field absent and nothing to derive from — keep whatever it had.
|
||||
nextDayId = current.day_id ?? null;
|
||||
} else {
|
||||
nextDayId = null;
|
||||
nextDayId = current.day_id ?? null;
|
||||
}
|
||||
|
||||
let nextEndDayId: number | null;
|
||||
|
||||
@@ -10,9 +10,6 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
'dark_mode',
|
||||
'time_format',
|
||||
// Instance-wide default currency for Costs (new users inherit it until they
|
||||
// pick their own). Free-form ISO code, validated on the client.
|
||||
'default_currency',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
||||
|
||||
@@ -16,11 +16,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// Loopback
|
||||
if (addr.startsWith('127.') || addr === '::1') return true;
|
||||
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (addr.startsWith('0.')) return true;
|
||||
if (addr.startsWith("0.")) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
|
||||
@@ -32,9 +32,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// RFC-1918 private ranges
|
||||
if (addr.startsWith('10.')) return true;
|
||||
if (addr.startsWith("10.")) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||
if (addr.startsWith('192.168.')) return true;
|
||||
if (addr.startsWith("192.168.")) return true;
|
||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
@@ -71,9 +71,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
try {
|
||||
const result = await dns.lookup(hostname);
|
||||
resolvedIp = result.address;
|
||||
} catch (error_) {
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
|
||||
}
|
||||
|
||||
if (isAlwaysBlocked(resolvedIp)) {
|
||||
@@ -91,8 +90,7 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp,
|
||||
error:
|
||||
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
};
|
||||
}
|
||||
return { allowed: true, isPrivate: true, resolvedIp };
|
||||
@@ -189,7 +187,7 @@ export async function safeFetchFollow(
|
||||
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
|
||||
const status = typeof response.status === 'number' ? response.status : 0;
|
||||
const isRedirectStatus = status >= 300 && status < 400;
|
||||
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
|
||||
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -98,28 +98,6 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () =>
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).toMatch(/Max-Age=\d+/i);
|
||||
// 30d default — well above the 24h (86400s) non-remember window.
|
||||
const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]);
|
||||
expect(maxAge).toBeGreaterThan(86_400);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).not.toMatch(/Max-Age/i);
|
||||
expect(cookie).not.toMatch(/Expires/i);
|
||||
}, 10000);
|
||||
|
||||
it('POST /logout clears the session cookie', async () => {
|
||||
const res = await request(server).post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -31,7 +31,6 @@ const { svc } = vi.hoisted(() => ({
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||
setItemPayers: vi.fn(), listSettlements: vi.fn(), createSettlement: vi.fn(), updateSettlement: vi.fn(), deleteSettlement: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => svc);
|
||||
@@ -105,18 +104,4 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
});
|
||||
|
||||
it('200 on settlement update with permission', async () => {
|
||||
svc.updateSettlement.mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
});
|
||||
|
||||
it('404 on settlement update when it does not exist', async () => {
|
||||
svc.updateSettlement.mockReturnValue(null);
|
||||
const res = await request(server).put('/api/trips/5/budget/settlements/7').set('Cookie', sessionCookie(1)).send({ from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Settlement not found' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,44 +185,6 @@ describe('Update reservation', () => {
|
||||
expect(res.body.reservation.confirmation_number).toBe('ABC123');
|
||||
});
|
||||
|
||||
it('RESV-004b — PUT with day_id null derives day_id from reservation_time so it stays in the Plan (#1237)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createDay(testDb, trip.id, { date: '2025-09-01' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2025-09-02' });
|
||||
const resv = createReservation(testDb, trip.id, { title: 'Event', type: 'event' });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resv.id}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', day_id: null, reservation_time: '2025-09-02' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation.day_id).toBe(day2.id);
|
||||
});
|
||||
|
||||
it('RESV-004c — re-dating a booking moves it to the matching day (start + end) (#1237)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2025-10-01' });
|
||||
const day3 = createDay(testDb, trip.id, { date: '2025-10-03' });
|
||||
|
||||
// Booking sits on day 1 (start + end).
|
||||
const created = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', day_id: day1.id, reservation_time: '2025-10-01T09:00', reservation_end_time: '2025-10-01T10:00' });
|
||||
const rid = created.body.reservation.id;
|
||||
|
||||
// Re-date to day 3 WITHOUT sending day_id (the modal omits it) — both ends follow.
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${rid}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Event', type: 'event', reservation_time: '2025-10-03T00:00', reservation_end_time: '2025-10-03T14:00' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.reservation.day_id).toBe(day3.id);
|
||||
expect(res.body.reservation.end_day_id).toBe(day3.id);
|
||||
});
|
||||
|
||||
it('RESV-004 — PUT on non-existent reservation returns 404', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
@@ -420,7 +382,7 @@ describe('Reservation budget entry integration', () => {
|
||||
expect(items[0].total_price).toBe(150);
|
||||
});
|
||||
|
||||
it('RESV-014 — PUT without create_budget_entry keeps the existing linked budget item', async () => {
|
||||
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
@@ -436,98 +398,24 @@ describe('Reservation budget entry integration', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Verify budget item exists
|
||||
const before = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
|
||||
// linked expense (expenses are managed from the Costs section now).
|
||||
// Update without create_budget_entry — should delete the linked budget item
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi Updated' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeDefined();
|
||||
});
|
||||
|
||||
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({
|
||||
title: 'Taxi',
|
||||
type: 'transport',
|
||||
create_budget_entry: { total_price: 50, category: 'Transport' },
|
||||
});
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Explicit clear (total_price 0) still removes the linked item.
|
||||
const updateRes = await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeUndefined();
|
||||
});
|
||||
|
||||
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Change the type other -> hotel (no create_budget_entry).
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
expect(item.category).toBe('accommodation');
|
||||
});
|
||||
|
||||
it('RESV-014d — a manually-picked expense category survives a booking type change', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const createRes = await request(app)
|
||||
.post(`/api/trips/${trip.id}/reservations`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Simulate a manual category pick in the Costs editor.
|
||||
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
expect(item.category).toBe('fees');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reservation accommodation delete', () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AddonsController } from '../../../src/nest/addons/addons.controller';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function makeService(overrides: Partial<AddonsService> = {}): AddonsService {
|
||||
return {
|
||||
list: vi.fn().mockReturnValue({ collabFeatures: {}, bagTracking: false, addons: [] }),
|
||||
...overrides,
|
||||
} as unknown as AddonsService;
|
||||
}
|
||||
|
||||
describe('AddonsController (parity with the legacy GET /api/addons route)', () => {
|
||||
it('GET / delegates straight to the service and returns its feed', () => {
|
||||
const feed = {
|
||||
collabFeatures: { comments: true },
|
||||
bagTracking: true,
|
||||
addons: [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }],
|
||||
};
|
||||
const list = vi.fn().mockReturnValue(feed);
|
||||
const svc = makeService({ list } as Partial<AddonsService>);
|
||||
|
||||
expect(new AddonsController(svc).list()).toBe(feed);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(list).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields).
|
||||
// A single shared statement is reused, so .all() is fed result sets in call order.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
|
||||
getBagTracking: vi.fn(() => ({ enabled: false })),
|
||||
getCollabFeatures: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures }));
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function svc() {
|
||||
return new AddonsService();
|
||||
}
|
||||
|
||||
// Feed the three reads in order: addons, providers, fields.
|
||||
function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) {
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce(addons)
|
||||
.mockReturnValueOnce(providers)
|
||||
.mockReturnValueOnce(fields);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMock._stmt.all.mockReturnValue([]);
|
||||
getCollabFeatures.mockReturnValue({});
|
||||
getBagTracking.mockReturnValue({ enabled: false });
|
||||
getPhotoProviderConfig.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from the admin service', () => {
|
||||
getCollabFeatures.mockReturnValue({ comments: true });
|
||||
getBagTracking.mockReturnValue({ enabled: true });
|
||||
feedReads([], [], []);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.collabFeatures).toEqual({ comments: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces the addon enabled column to a boolean (both 1 and 0)', () => {
|
||||
feedReads(
|
||||
[
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' });
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
icon: 'image',
|
||||
enabled: true,
|
||||
config: { baseUrl: 'http://x' },
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich');
|
||||
});
|
||||
|
||||
it('coerces a disabled photo provider enabled flag to false', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('groups multiple fields under their provider and keeps insertion order', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: 1,
|
||||
secret: 0,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: null,
|
||||
hint: null,
|
||||
required: 0,
|
||||
secret: 1,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const provider = res.addons[0] as { fields: Array<Record<string, unknown>> };
|
||||
expect(provider.fields).toEqual([
|
||||
{
|
||||
key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: true,
|
||||
secret: false,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
required: false,
|
||||
secret: true,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'p',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
// placeholder/hint/settings_key/payload_key omitted entirely (undefined)
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const field = (res.addons[0] as { fields: Array<Record<string, unknown>> }).fields[0];
|
||||
expect(field).toMatchObject({
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fields belonging to other providers out of a provider with none of its own', () => {
|
||||
// A field exists, but for a DIFFERENT provider than the one returned — exercises
|
||||
// the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty.
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'other',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('concatenates regular addons before the photo providers', () => {
|
||||
feedReads(
|
||||
[{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']);
|
||||
expect((res.addons[1] as { type: string }).type).toBe('photo_provider');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mock
|
||||
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
||||
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import { send as sendNotification } from '../../../src/services/notificationService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
@@ -122,114 +121,6 @@ describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController error envelope fallbacks', () => {
|
||||
it('ok() defaults to 400 when the error envelope omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
|
||||
});
|
||||
|
||||
it('updateOidc defaults to 400 when the service error omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('updateOidc audits issuer_set=false when no issuer is supplied', () => {
|
||||
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController read-only getters', () => {
|
||||
it('return service values verbatim', () => {
|
||||
expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
||||
expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
|
||||
expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
|
||||
expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
||||
expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
|
||||
expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
||||
expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
|
||||
expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
|
||||
expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
|
||||
expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
||||
expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).getDefaultUserSettings()).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('setNotificationPrefs persists then returns the refreshed matrix', () => {
|
||||
const setAdminPreferences = vi.fn();
|
||||
const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial<AdminService>));
|
||||
expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] });
|
||||
expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 });
|
||||
});
|
||||
|
||||
it('githubReleases falls back to default paging when no query is given', async () => {
|
||||
const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]);
|
||||
const c = new AdminController(svc({ getGithubReleases } as Partial<AdminService>));
|
||||
await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]);
|
||||
expect(getGithubReleases).toHaveBeenCalledWith('10', '1');
|
||||
await c.githubReleases('5', '2');
|
||||
expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController feature toggles + audit', () => {
|
||||
it('bag-tracking updates and audits', () => {
|
||||
const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>));
|
||||
expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' }));
|
||||
});
|
||||
|
||||
it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('places-details: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController packing template sub-routes', () => {
|
||||
it('update/delete templates, categories and items map errors + return success', () => {
|
||||
expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
|
||||
expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
|
||||
expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
|
||||
expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController tokens + sessions', () => {
|
||||
it('mcp token + oauth session deletes return success and map errors', () => {
|
||||
expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||
expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController default-user-settings error path', () => {
|
||||
it('400 with an Error message when setAdminUserDefaults throws an Error', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } });
|
||||
});
|
||||
|
||||
it('400 stringifies a non-Error throw', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } });
|
||||
});
|
||||
|
||||
it('400 when the body is null', () => {
|
||||
expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController dev test-notification', () => {
|
||||
it('404 outside development', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
@@ -241,23 +132,4 @@ describe('AdminController dev test-notification', () => {
|
||||
const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' });
|
||||
expect(res).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('applies notification defaults when the body is empty', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = await new AdminController(svc()).devTestNotification(user, {});
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id }));
|
||||
});
|
||||
|
||||
it('maps an Error from the notification service to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('send failed'));
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } });
|
||||
});
|
||||
|
||||
it('stringifies a non-Error notification failure to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce('weird');
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,264 +1,26 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() }));
|
||||
vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/passkeyService', () => ({
|
||||
passkeyRegisterOptions: vi.fn(),
|
||||
passkeyRegisterVerify: vi.fn(),
|
||||
passkeyLoginOptions: vi.fn(),
|
||||
passkeyLoginVerify: vi.fn(),
|
||||
listPasskeys: vi.fn(),
|
||||
renamePasskey: vi.fn(),
|
||||
deletePasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard';
|
||||
import { OptionalJwtGuard } from '../../../src/nest/auth/optional-jwt.guard';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard';
|
||||
import { PasskeyController } from '../../../src/nest/auth/passkey.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import { CurrentUser } from '../../../src/nest/auth/current-user.decorator';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth';
|
||||
import { resolveAuthToggles } from '../../../src/services/authService';
|
||||
import { setAuthCookie } from '../../../src/services/cookie';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as passkey from '../../../src/services/passkeyService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function context(req: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||
}
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
|
||||
let thrown: unknown;
|
||||
try {
|
||||
guard.canActivate(context({ headers: {}, cookies: {} }));
|
||||
} catch (e) {
|
||||
thrown = e;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(HttpException);
|
||||
expect((thrown as HttpException).getStatus()).toBe(401);
|
||||
expect((thrown as HttpException).getResponse()).toEqual({
|
||||
error: 'Access token required',
|
||||
code: 'AUTH_REQUIRED',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid/expired token (verify returns null)', () => {
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the loaded user and allows a valid token through', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CookieAuthGuard', () => {
|
||||
const guard = new CookieAuthGuard();
|
||||
|
||||
it('401s when the trek_session cookie is missing', () => {
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
// and when there is no cookies object at all
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('401s when the cookie token fails verification', () => {
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the user and allows a valid cookie session through', () => {
|
||||
const req: Record<string, unknown> = { cookies: { trek_session: 'tok' } };
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptionalJwtGuard', () => {
|
||||
const guard = new OptionalJwtGuard();
|
||||
|
||||
it('always allows; sets req.user to null when no token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets req.user to null when a token verifies to nothing', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
});
|
||||
|
||||
it('populates req.user from a valid token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const guard = new AdminGuard();
|
||||
|
||||
it('403s for anonymous and for a non-admin role', () => {
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
});
|
||||
|
||||
it('allows an admin through', () => {
|
||||
expect(guard.canActivate(context({ user: { role: 'admin' } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyEnabledGuard', () => {
|
||||
const guard = new PasskeyEnabledGuard();
|
||||
|
||||
it('404s when passkey_login is off', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: false } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
|
||||
});
|
||||
|
||||
it('allows when passkey_login is on', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: true } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CurrentUser decorator', () => {
|
||||
// Apply the decorator to a throwaway handler so Nest stores the param factory in
|
||||
// route metadata, then invoke that factory exactly as the framework would.
|
||||
function paramFactory(): (data: unknown, ctx: unknown) => User | undefined {
|
||||
class Target { handler(_u: User) {} }
|
||||
(CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0);
|
||||
const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record<string, { factory: (data: unknown, ctx: unknown) => User | undefined }>;
|
||||
return Object.values(meta)[0].factory;
|
||||
}
|
||||
|
||||
it('resolves the authenticated user from the request', () => {
|
||||
expect(paramFactory()(undefined, context({ user }))).toBe(user);
|
||||
});
|
||||
|
||||
it('returns undefined when no user is attached', () => {
|
||||
expect(paramFactory()(undefined, context({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyController', () => {
|
||||
const req = { ip: '9.9.9.9' } as Request;
|
||||
const res = {} as never;
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
|
||||
it('register/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never });
|
||||
expect(await new PasskeyController(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' });
|
||||
});
|
||||
|
||||
it('register/verify maps a service error, else audits and returns the credential', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } });
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never);
|
||||
expect(await new PasskeyController(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' }));
|
||||
});
|
||||
|
||||
it('login/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } });
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never);
|
||||
expect(await new PasskeyController(rl()).loginOptions(req)).toEqual({ challenge: 'd' });
|
||||
});
|
||||
|
||||
it('login/verify audits a failure then maps the error, padding latency', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' }));
|
||||
}, 10000);
|
||||
|
||||
it('login/verify sets the session cookie and audits login on success', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never);
|
||||
expect(await new PasskeyController(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } }));
|
||||
}, 10000);
|
||||
|
||||
it('credentials: list, rename (error + success), delete (error + success)', () => {
|
||||
vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]);
|
||||
expect(new PasskeyController(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 });
|
||||
expect(thrown(() => new PasskeyController(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(thrown(() => new PasskeyController(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' }));
|
||||
});
|
||||
|
||||
it('throttles registration and login ceremonies once the bucket is exhausted', async () => {
|
||||
const s = new RateLimitService();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
|
||||
const s2 = new RateLimitService();
|
||||
for (let i = 0; i < 10; i++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never);
|
||||
const noIp = {} as Request;
|
||||
expect(await new PasskeyController(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,18 +51,6 @@ describe('RateLimitService', () => {
|
||||
expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket
|
||||
expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset
|
||||
});
|
||||
|
||||
it('reset clears a single named bucket, and reset() clears all of them', () => {
|
||||
const s = rl();
|
||||
s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap
|
||||
s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(false);
|
||||
s.reset('login'); // only the login bucket
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(true);
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched
|
||||
s.reset(); // everything
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPublicController', () => {
|
||||
@@ -94,10 +82,9 @@ describe('AuthPublicController', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
|
||||
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
|
||||
// The "remember me" flag from the service rides through to the cookie service.
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
|
||||
}, 10000);
|
||||
@@ -116,71 +103,6 @@ describe('AuthPublicController', () => {
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('app-config forwards the optional user (present and absent)', () => {
|
||||
const getAppConfig = vi.fn().mockReturnValue({ version: '3' });
|
||||
const c = new AuthPublicController(asvc({ getAppConfig } as Partial<AuthService>), rl());
|
||||
expect(c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(user);
|
||||
expect(c.appConfig({} as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('invite maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||
});
|
||||
|
||||
it('login takes the mfa-required branch and never sets a cookie', async () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
expect(setAuthCookie).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => {
|
||||
// Non-issued (unknown email / throttled): audits the reason, no email sent.
|
||||
const sendNever = vi.fn();
|
||||
const skip = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial<AuthService>), rl());
|
||||
expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true });
|
||||
expect(sendNever).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } }));
|
||||
// Issued but the mailer throws: swallowed, audited as failed, still ok.
|
||||
const boom = vi.fn().mockRejectedValue(new Error('smtp'));
|
||||
const fail = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial<AuthService>), rl());
|
||||
expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password ignores a non-string email body', async () => {
|
||||
const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null });
|
||||
const c = new AuthPublicController(asvc({ requestPasswordReset } as Partial<AuthService>), rl());
|
||||
expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true });
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String));
|
||||
}, 10000);
|
||||
|
||||
it('reset-password 429 once the dedicated reset bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
||||
});
|
||||
|
||||
it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login sets cookie + audits; logout clears cookie', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
@@ -244,88 +166,4 @@ describe('AuthController (authenticated)', () => {
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('change-password refreshes this device cookie when the service returns a token', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.changePassword(user, {}, req, res)).toEqual({ success: true });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
|
||||
});
|
||||
|
||||
it('delete-account maps error, else audits and succeeds', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
||||
expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
||||
});
|
||||
|
||||
it('maps-key + api-keys pass straight through to the service', () => {
|
||||
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
|
||||
expect(new AuthController(asvc({ updateMapsKey } as Partial<AuthService>), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
|
||||
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
|
||||
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
|
||||
expect(new AuthController(asvc({ updateApiKeys } as Partial<AuthService>), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it('update-settings + get-settings map errors, else return their payloads', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
|
||||
expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
|
||||
expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
||||
expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
||||
});
|
||||
|
||||
it('delete-avatar + users + travel-stats delegate to the service', async () => {
|
||||
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
|
||||
expect(await new AuthController(asvc({ deleteAvatar } as Partial<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
|
||||
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new AuthController(asvc({ listUsers } as Partial<AuthService>), rl()).users(user)).toEqual({ users: [{ id: 1 }] });
|
||||
expect(listUsers).toHaveBeenCalledWith(1);
|
||||
const getTravelStats = vi.fn().mockReturnValue({ countries: 3 });
|
||||
expect(new AuthController(asvc({ getTravelStats } as Partial<AuthService>), rl()).travelStats(user)).toEqual({ countries: 3 });
|
||||
});
|
||||
|
||||
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
||||
expect(await thrownAsync(() => new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial<AuthService>), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
||||
const ok = new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
||||
});
|
||||
|
||||
it('app-settings get maps error, else returns data; put maps error, else audits', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
||||
});
|
||||
|
||||
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
||||
const c = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
||||
});
|
||||
|
||||
it('mfa/enable + mfa/disable map errors', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
||||
expect(thrown(() => new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
||||
const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
|
||||
expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
|
||||
});
|
||||
|
||||
it('mcp-tokens list + create error + delete error/success', () => {
|
||||
expect(new AuthController(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
||||
expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
||||
expect(thrown(() => new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('ws-token maps error, else returns the token', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
||||
expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), rl()).wsToken(user)).toEqual({ token: 'ws' });
|
||||
});
|
||||
|
||||
it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(false);
|
||||
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' });
|
||||
expect(await new AuthController(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,31 +3,13 @@ import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
// The controller imports the tmp-dir + size cap at module load. The thin
|
||||
// BackupService wrapper forwards every call straight into this module, so the
|
||||
// mock also stubs the delegated functions for the wrapper tests below.
|
||||
vi.mock('../../../src/services/backupService', () => ({
|
||||
getUploadTmpDir: () => '/tmp',
|
||||
MAX_BACKUP_UPLOAD_SIZE: 1024,
|
||||
BACKUP_RATE_WINDOW: 3600000,
|
||||
listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]),
|
||||
createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }),
|
||||
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
|
||||
getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }),
|
||||
updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }),
|
||||
deleteBackup: vi.fn(),
|
||||
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
||||
backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'),
|
||||
backupFileExists: vi.fn().mockReturnValue(true),
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
// The controller imports the tmp-dir + size cap at module load.
|
||||
vi.mock('../../../src/services/backupService', () => ({ getUploadTmpDir: () => '/tmp', MAX_BACKUP_UPLOAD_SIZE: 1024 }));
|
||||
|
||||
import { BackupController } from '../../../src/nest/backup/backup.controller';
|
||||
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as backupSvc from '../../../src/services/backupService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
||||
@@ -104,17 +86,12 @@ describe('BackupController', () => {
|
||||
|
||||
it('POST /restore maps the service status, else audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
|
||||
});
|
||||
|
||||
it('POST /restore falls back to status 400 when the service omits one', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
@@ -131,14 +108,6 @@ describe('BackupController', () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
|
||||
const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, ok, req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' }));
|
||||
});
|
||||
|
||||
it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
|
||||
@@ -154,20 +123,6 @@ describe('BackupController', () => {
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'production';
|
||||
const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings tolerates a missing body', () => {
|
||||
const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
|
||||
new BackupController(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
|
||||
expect(updateAutoSettings).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('GET/PUT /auto-settings', () => {
|
||||
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
|
||||
const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
|
||||
@@ -183,50 +138,3 @@ describe('BackupController', () => {
|
||||
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupService (wrapper)', () => {
|
||||
const wrapper = new RealBackupService();
|
||||
|
||||
it('forwards every call straight to the legacy backup service', async () => {
|
||||
expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
|
||||
expect(backupSvc.listBackups).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 });
|
||||
expect(backupSvc.createBackup).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
|
||||
expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip');
|
||||
|
||||
expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' });
|
||||
expect(backupSvc.getAutoSettings).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 });
|
||||
expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true });
|
||||
|
||||
wrapper.deleteBackup('svc.zip');
|
||||
expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
|
||||
expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip');
|
||||
expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFileExists('svc.zip')).toBe(true);
|
||||
expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true);
|
||||
expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000);
|
||||
});
|
||||
|
||||
it('exposes the legacy rate window', () => {
|
||||
expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupModule', () => {
|
||||
it('wires the controller and service together', async () => {
|
||||
const { BackupModule } = await import('../../../src/nest/backup/backup.module');
|
||||
expect(new BackupModule()).toBeInstanceOf(BackupModule);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,106 +42,12 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement,
|
||||
settlement: vi.fn().mockReturnValue({ transfers: [] }),
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
});
|
||||
|
||||
it('GET /settlement forwards the base query and the trip currency', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
new BudgetController(svc).settlement(user, '5', 'GBP');
|
||||
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD');
|
||||
});
|
||||
|
||||
describe('settlements ledger', () => {
|
||||
it('GET /settlements lists', () => {
|
||||
const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST /settlements 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { to_user_id: 2, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements creates and broadcasts (amount 0 is allowed)', () => {
|
||||
const createSettlement = vi.fn().mockReturnValue({ id: 3, amount: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
|
||||
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0 }, user.id);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id success broadcasts the numeric id', () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ updateSettlement: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /settlements/:id updates and broadcasts', () => {
|
||||
const updateSettlement = vi.fn().mockReturnValue({ id: 7, from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).updateSettlement(user, '5', '7', { from_user_id: 2, to_user_id: 1, amount: 15 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } });
|
||||
expect(updateSettlement).toHaveBeenCalledWith('7', '5', { from_user_id: 2, to_user_id: 1, amount: 15 });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-updated', { settlement: { id: 7, from_user_id: 2, to_user_id: 1, amount: 15 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
@@ -218,31 +124,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/payers', () => {
|
||||
it('400 when payers is not an array', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).setPayers(user, '5', '9', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'payers must be an array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }]))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets payers and broadcasts budget:updated', () => {
|
||||
const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ setPayers, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }], 'sock');
|
||||
expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } });
|
||||
expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
|
||||
const broadcast = vi.fn();
|
||||
@@ -251,14 +132,6 @@ describe('BudgetController (parity with the legacy /api/trips/:tripId/budget rou
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
||||
new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', false, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, success otherwise', () => {
|
||||
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() }));
|
||||
vi.mock('../../../src/services/exchangeRateService', () => ({ getRates }));
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
listBudgetItems: vi.fn(),
|
||||
getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
updateMembers: vi.fn(),
|
||||
toggleMemberPaid: vi.fn(),
|
||||
setItemPayers: vi.fn(),
|
||||
listSettlements: vi.fn(),
|
||||
createSettlement: vi.fn(),
|
||||
deleteSettlement: vi.fn(),
|
||||
reorderBudgetItems: vi.fn(),
|
||||
reorderBudgetCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
function svc() {
|
||||
return new BudgetService();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('BudgetService', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('canEdit marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary delegate', () => {
|
||||
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
|
||||
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
||||
await svc().settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
});
|
||||
});
|
||||
|
||||
it('create / update / remove / members / paid / payers delegate', () => {
|
||||
svc().create('5', { name: 'Hotel' } as never);
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
svc().update('9', '5', { name: 'X' });
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
svc().remove('9', '5');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
|
||||
svc().updateMembers('9', '5', [2, 3]);
|
||||
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
|
||||
svc().toggleMemberPaid('9', '5', '2', true);
|
||||
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
|
||||
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('settlement ledger + reorder delegate', () => {
|
||||
svc().listSettlements('5');
|
||||
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
||||
svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
svc().deleteSettlement('7', '5');
|
||||
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
|
||||
svc().reorderItems('5', [3, 1]);
|
||||
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
svc().reorderCategories('5', ['food', 'fun']);
|
||||
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
});
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
it('returns early when the reservation is not found', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges into existing metadata and broadcasts reservation:updated', () => {
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
|
||||
});
|
||||
|
||||
it('starts from an empty object when the reservation has no metadata', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
|
||||
svc().syncReservationPrice('5', 42, 99, undefined);
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ price: '99' });
|
||||
});
|
||||
|
||||
it('swallows errors so a sync failure never breaks the budget update', () => {
|
||||
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
|
||||
expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user