mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-23 07:11:46 +00:00
Compare commits
29 Commits
v3.0.16
...
5d53c5f678
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d53c5f678 | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| b8019dfc8c | |||
| fde4cc752c | |||
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 7498a3142f | |||
| 499097fa3c | |||
| 002ea91be8 | |||
| 73fb48e3bb | |||
| d30132197e | |||
| 9bf4220054 | |||
| 0d0ab5080c | |||
| 1084d40685 | |||
| 75ef928264 |
@@ -0,0 +1,37 @@
|
||||
name: Security Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
scout:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
exit-code: true
|
||||
@@ -8,10 +8,33 @@ on:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- '.github/workflows/test.yml'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: shared/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd shared && npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: cd shared && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,6 +51,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd server && npm ci
|
||||
|
||||
- name: Build (tsc + tsc-alias -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Typecheck (informational)
|
||||
# Legacy code still has pre-existing type errors; this surfaces them
|
||||
# without blocking the migration. Ratchet to blocking once cleaned up.
|
||||
continue-on-error: true
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && npm run test:coverage
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
server/dist/
|
||||
server/public/*
|
||||
!server/public/.gitkeep
|
||||
|
||||
|
||||
+19
-6
@@ -1,13 +1,24 @@
|
||||
# Stage 1: Build React client
|
||||
FROM node:22-alpine AS client-builder
|
||||
FROM node:24-alpine AS client-builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production server
|
||||
FROM node:22-alpine
|
||||
# Stage 2: Build server (TypeScript -> dist via tsc + tsc-alias)
|
||||
# --ignore-scripts: tsc only transpiles, so we skip native builds (better-sqlite3)
|
||||
# here; the production stage builds the native module.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY server/package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY server/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production server (runs the compiled JS — NestJS DI needs the
|
||||
# decorator metadata that tsc emits; the old tsx runtime did not).
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,9 +26,11 @@ WORKDIR /app
|
||||
COPY server/package*.json ./
|
||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||
npm ci --production && \
|
||||
apk del python3 make g++
|
||||
rm package-lock.json && \
|
||||
apk del python3 make g++ && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
COPY server/ ./
|
||||
COPY --from=server-builder /app/dist ./dist
|
||||
COPY --from=client-builder /app/client/dist ./public
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
@@ -36,4 +49,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node dist/index.js"]
|
||||
|
||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
|
||||
If you discover a security vulnerability, please report it responsibly:
|
||||
|
||||
1. **Do not** open a public issue
|
||||
2. Email: **mauriceboe@icloud.com**
|
||||
2. Email: **report@liketrek.com**
|
||||
3. Include a description of the vulnerability and steps to reproduce
|
||||
|
||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.16
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.16"
|
||||
appVersion: "3.0.22"
|
||||
|
||||
Generated
+301
-1740
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.16",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -18,6 +18,7 @@
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"heic-to": "^1.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
@@ -34,6 +35,7 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { WeatherResult } from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { isReachable, probeNow } from '../sync/connectivity'
|
||||
import en from '../i18n/translations/en'
|
||||
@@ -209,7 +210,7 @@ export const oauthApi = {
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
@@ -407,8 +408,20 @@ export const journeyApi = {
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||
timeout: 0,
|
||||
onUploadProgress: opts?.onUploadProgress,
|
||||
signal: opts?.signal,
|
||||
}).then(r => r.data),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
@@ -489,8 +502,8 @@ export const reservationsApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||
<button
|
||||
|
||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -162,6 +163,7 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
@@ -442,6 +444,35 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Travel-time pills between consecutive places. The GL map accepted the
|
||||
// routeSegments prop but never drew anything, so the labels that Leaflet
|
||||
// shows were missing here (#850). Render them as HTML markers, matching the
|
||||
// Leaflet pill styling.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
for (const seg of routeSegments) {
|
||||
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
|
||||
const el = document.createElement('div')
|
||||
el.style.pointerEvents = 'none'
|
||||
el.innerHTML = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
||||
</div>`
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([seg.mid[1], seg.mid[0]])
|
||||
.addTo(map)
|
||||
routeLabelMarkersRef.current.push(m)
|
||||
}
|
||||
return () => {
|
||||
routeLabelMarkersRef.current.forEach(m => m.remove())
|
||||
routeLabelMarkersRef.current = []
|
||||
}
|
||||
}, [routeSegments, mapReady])
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||
export function wantsTerrain(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||
}
|
||||
|
||||
// 3D can be added to every style now — the standard family has it built-in
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
if (!_renderToStaticMarkup) return ''
|
||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const phase = pdfGetSpanPhase(r, day.id)
|
||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
||||
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
|
||||
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||
import PackingListPanel from './PackingListPanel';
|
||||
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||
|
||||
describe('itemWeight (bag total weight calc)', () => {
|
||||
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
|
||||
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
|
||||
});
|
||||
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
|
||||
expect(itemWeight({ weight_grams: 250 })).toBe(250);
|
||||
});
|
||||
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
|
||||
expect(itemWeight({ quantity: 5 })).toBe(0);
|
||||
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
|
||||
expect(itemWeight({})).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
|
||||
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
|
||||
|
||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||
|
||||
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||
(i.weight_grams || 0) * (i.quantity || 1)
|
||||
|
||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface BagCardProps {
|
||||
@@ -1311,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||
@@ -1322,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = items.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||
@@ -1342,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1380,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||
@@ -1391,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = items.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||
@@ -1411,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
@@ -57,9 +58,10 @@ interface DayDetailPanelProps {
|
||||
rightWidth?: number
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
mobile?: boolean
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -173,7 +175,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
||||
<div className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||
<div style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -288,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
{/* ── Reservations for this day's assignments ── */}
|
||||
{(() => {
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
||||
const dayReservations = reservations.filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||
return r.day_id === day.id
|
||||
})
|
||||
if (dayReservations.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
@@ -305,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||
if (!startTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -23,7 +23,12 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
@@ -362,26 +367,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day (by id)
|
||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
// Get phase label for multi-day badge
|
||||
const getSpanLabel = (r: Reservation, phase: string): string | null => {
|
||||
if (phase === 'single') return null
|
||||
@@ -406,27 +391,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.has(r.type)) return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
const getTransportForDay = (dayId: number) =>
|
||||
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
@@ -446,20 +412,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
|
||||
const parseTimeToMinutes = (time?: string | null): number | null => {
|
||||
if (!time) return null
|
||||
// ISO-Format "2025-03-30T09:00:00"
|
||||
if (time.includes('T')) {
|
||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
// Einfaches "HH:MM" Format
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||
return null
|
||||
}
|
||||
|
||||
// Compute initial day_plan_position for a transport based on time
|
||||
const computeTransportPosition = (r, da) => {
|
||||
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
|
||||
@@ -501,64 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
|
||||
const getMergedItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
|
||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
||||
const baseItems = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
// Transports are inserted among places based on time
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
|
||||
}
|
||||
|
||||
// Insert transports among places based on per-day position or time
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// Use per-day position if explicitly set by user reorder
|
||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||
if (perDayPos != null) {
|
||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Find insertion position: after the last place with time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||
} else if (item.type === 'transport') {
|
||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||
const sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||
_getMergedItems({
|
||||
dayAssignments: getDayAssignments(dayId),
|
||||
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||
dayTransports: getTransportForDay(dayId),
|
||||
dayId,
|
||||
getDisplayTime: getDisplayTimeForDay,
|
||||
})
|
||||
|
||||
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1585,15 +1487,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${(() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
})()}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!st && !et) return null
|
||||
return (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
@@ -1820,18 +1724,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{res.title}
|
||||
</span>
|
||||
{displayTime?.includes('T') && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
||||
})()}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
if (!dispTime && !endTime) return null
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||
<Clock size={9} strokeWidth={2} />
|
||||
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
@@ -1880,8 +1786,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
// New place dropped onto a note: insert it among the
|
||||
// assignments at the note's position (after the places
|
||||
// above it), so it lands right where the note sits.
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
@@ -2192,13 +2107,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
{(() => {
|
||||
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
const dateStr = date
|
||||
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||
const parts: string[] = []
|
||||
if (dateStr) parts.push(dateStr)
|
||||
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||
return parts.join(', ')
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime } from '../../utils/formatters'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -169,7 +170,10 @@ export default function PlaceInspector({
|
||||
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
||||
const assignmentInDay = selectedDayId
|
||||
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
|
||||
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||
: null
|
||||
|
||||
const openingHours = googleDetails?.opening_hours || null
|
||||
const openNow = googleDetails?.open_now ?? null
|
||||
@@ -344,7 +348,7 @@ export default function PlaceInspector({
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -378,21 +382,29 @@ export default function PlaceInspector({
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
|
||||
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
|
||||
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
|
||||
const day = buildDay({ date: null, day_number: 25 } as any);
|
||||
const r = buildReservation({
|
||||
title: 'Cruise test',
|
||||
type: 'cruise',
|
||||
status: 'pending',
|
||||
reservation_time: 'T10:00',
|
||||
reservation_end_time: 'T18:00',
|
||||
day_id: day.id,
|
||||
end_day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
|
||||
const day = buildDay({ date: null, day_number: 3 } as any);
|
||||
const r = buildReservation({
|
||||
title: 'Car rental',
|
||||
type: 'car',
|
||||
status: 'pending',
|
||||
reservation_time: '09:00',
|
||||
reservation_end_time: '17:00',
|
||||
day_id: day.id,
|
||||
end_day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/09:00/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
|
||||
const day = buildDay({ date: '2026-07-15', day_number: 1 });
|
||||
const r = buildReservation({
|
||||
title: 'Flight out',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-07-15T08:30',
|
||||
reservation_end_time: '2026-07-15T10:45',
|
||||
day_id: day.id,
|
||||
} as any);
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/08:30/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
@@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const fmtDate = (str) => {
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
const startDt = splitReservationDateTime(r.reservation_time)
|
||||
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||
const fmtDate = (date: string) =>
|
||||
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const hasDate = !!r.reservation_time
|
||||
const hasTime = r.reservation_time?.includes('T')
|
||||
const hasDate = !!startDt.date
|
||||
const hasTime = !!(startDt.time || endDt.time)
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(r.reservation_time)}
|
||||
{(() => {
|
||||
const endDatePart = r.reservation_end_time
|
||||
? r.reservation_end_time.includes('T')
|
||||
? r.reservation_end_time.split('T')[0]
|
||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
||||
? r.reservation_end_time
|
||||
: null
|
||||
: null
|
||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
||||
})() && (
|
||||
<> – {fmtDate(r.reservation_end_time)}</>
|
||||
)}
|
||||
{(hasDate || hasTime) && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
{hasDate && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtDate(startDt.date!)}
|
||||
{endDt.date && endDt.date !== startDt.date && (
|
||||
<> – {fmtDate(endDt.date)}</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasTime && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
||||
{formatTime(startDt.time, locale, timeFormat)}
|
||||
{endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||
if (cells.length === 0) return null
|
||||
return (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
@@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
status: reservation.status || 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
@@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||
if (!time) return null
|
||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
||||
return day?.date ? `${day.date}T${time}` : time
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
|
||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
allows_client_credentials: boolean
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [oauthRotating, setOauthRotating] = useState(false)
|
||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||
|
||||
// MCP sub-tab state
|
||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
if (!oauthNewName.trim()) return
|
||||
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
||||
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({
|
||||
name: oauthNewName.trim(),
|
||||
redirect_uris: uris,
|
||||
allowed_scopes: oauthNewScopes,
|
||||
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||
})
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
setOauthIsMachine(false)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} finally {
|
||||
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||
</button>
|
||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<div className="flex items-center gap-3">
|
||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||
{client.allows_client_credentials && (
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
|
||||
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
|
||||
{t('settings.oauth.badge.machine')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.clientId')}: {client.client_id}
|
||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
autoFocus />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</div>
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<div>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!oauthIsMachine && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateOAuthClient}
|
||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
||||
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||
</button>
|
||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{oauthCreatedClient?.allows_client_credentials && (
|
||||
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||
{t('settings.oauth.modal.machineClientUsage')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
|
||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const imageUrlFailed = useRef(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
|
||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
||||
alt={place.name}
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={() => setPhotoSrc(null)}
|
||||
onError={() => {
|
||||
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||
imageUrlFailed.current = true
|
||||
const photoId = place.google_place_id || place.osm_id!
|
||||
const cacheKey = `refetch:${photoId}`
|
||||
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||
)
|
||||
} else {
|
||||
setPhotoSrc(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import ar from './translations/ar'
|
||||
import br from './translations/br'
|
||||
import cs from './translations/cs'
|
||||
import pl from './translations/pl'
|
||||
import ja from './translations/ja'
|
||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||
|
||||
export { SUPPORTED_LANGUAGES }
|
||||
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
|
||||
|
||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
|
||||
}
|
||||
|
||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
|
||||
|
||||
export function getIntlLanguage(language: string): string {
|
||||
if (language === 'br') return 'pt-BR'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
|
||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en'
|
||||
}
|
||||
|
||||
export function isRtlLanguage(language: string): boolean {
|
||||
|
||||
@@ -12,8 +12,9 @@ export const SUPPORTED_LANGUAGES = [
|
||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
||||
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
|
||||
] as const
|
||||
|
||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||
|
||||
@@ -330,6 +330,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
||||
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
||||
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
||||
'settings.oauth.modal.machineClient': 'عميل آلي (بدون تسجيل دخول عبر المتصفح)',
|
||||
'settings.oauth.modal.machineClientHint': 'استخدام منحة client_credentials — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
|
||||
'settings.oauth.modal.machineClientUsage': 'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
|
||||
'settings.oauth.badge.machine': 'آلي',
|
||||
'settings.account': 'الحساب',
|
||||
'settings.about': 'حول',
|
||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||
@@ -1674,6 +1678,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||
@@ -1705,8 +1710,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||
'journey.editor.uploadPhotos': 'رفع صور',
|
||||
'journey.editor.uploading': '...جارٍ الرفع',
|
||||
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||
'journey.editor.fromGallery': 'من المعرض',
|
||||
'journey.editor.addAnother': 'إضافة آخر',
|
||||
'journey.editor.makeFirst': 'جعله الأول',
|
||||
|
||||
@@ -402,6 +402,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sessão revogada',
|
||||
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
||||
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
||||
'settings.oauth.modal.machineClient': 'Cliente de máquina (sem login no navegador)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa o grant client_credentials — sem URIs de redirecionamento. O token é emitido diretamente via client_id + client_secret e age como você dentro dos escopos selecionados.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obter token: POST /oauth/token com grant_type=client_credentials, client_id e client_secret. Sem navegador, sem refresh token.',
|
||||
'settings.oauth.badge.machine': 'máquina',
|
||||
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||
|
||||
// Login
|
||||
@@ -2077,8 +2081,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||
'journey.editor.uploading': 'Enviando...',
|
||||
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
|
||||
'journey.editor.fromGallery': 'Da galeria',
|
||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||
'journey.editor.writeStory': 'Escreva sua história...',
|
||||
@@ -2169,6 +2176,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Falha ao excluir',
|
||||
'journey.entries.deleteTitle': 'Excluir entrada',
|
||||
'journey.photosUploaded': '{count} fotos enviadas',
|
||||
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
|
||||
'journey.photosAdded': '{count} fotos adicionadas',
|
||||
'journey.public.notFound': 'Não encontrado',
|
||||
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
||||
|
||||
@@ -281,6 +281,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Relace odvolána',
|
||||
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
||||
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
||||
'settings.oauth.modal.machineClient': 'Strojový klient (bez přihlášení v prohlížeči)',
|
||||
'settings.oauth.modal.machineClientHint': 'Používá grant client_credentials — bez URI pro přesměrování. Token je vydán přímo přes client_id + client_secret a funguje jako vy v rámci vybraných oborů.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Získat token: POST /oauth/token s grant_type=client_credentials, client_id a client_secret. Bez prohlížeče, bez obnovovacího tokenu.',
|
||||
'settings.oauth.badge.machine': 'strojový',
|
||||
'settings.account': 'Účet',
|
||||
'settings.about': 'O aplikaci',
|
||||
'settings.about.reportBug': 'Nahlásit chybu',
|
||||
@@ -2082,8 +2086,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'místa',
|
||||
'journey.synced.synced': 'synchronizováno',
|
||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||
'journey.editor.uploading': 'Nahrávání...',
|
||||
'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
|
||||
'journey.editor.fromGallery': 'Z galerie',
|
||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||
@@ -2174,6 +2181,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
||||
'journey.entries.deleteTitle': 'Smazat záznam',
|
||||
'journey.photosUploaded': '{count} fotografií nahráno',
|
||||
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
|
||||
'journey.photosAdded': '{count} fotografií přidáno',
|
||||
'journey.public.notFound': 'Nenalezeno',
|
||||
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
||||
|
||||
@@ -330,6 +330,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Session widerrufen',
|
||||
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
||||
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
||||
'settings.oauth.modal.machineClient': 'Maschineller Client (kein Browser-Login)',
|
||||
'settings.oauth.modal.machineClientHint': 'Verwendet den client_credentials Grant — keine Redirect-URIs erforderlich. Das Token wird direkt über client_id + client_secret ausgestellt und handelt in Ihrem Namen innerhalb der gewählten Scopes.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Token abrufen: POST /oauth/token mit grant_type=client_credentials, client_id und client_secret. Kein Browser, kein Refresh-Token.',
|
||||
'settings.oauth.badge.machine': 'Maschine',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'Über',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2085,8 +2089,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'Orte',
|
||||
'journey.synced.synced': 'synchronisiert',
|
||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||
'journey.editor.uploading': 'Hochladen...',
|
||||
'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
|
||||
'journey.editor.fromGallery': 'Aus Galerie',
|
||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||
@@ -2181,6 +2188,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
||||
'journey.entries.deleteTitle': 'Eintrag löschen',
|
||||
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
||||
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
|
||||
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
||||
'journey.public.notFound': 'Nicht gefunden',
|
||||
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
||||
|
||||
@@ -403,6 +403,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Session revoked',
|
||||
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
||||
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
||||
'settings.oauth.modal.machineClient': 'Machine client (no browser login)',
|
||||
'settings.oauth.modal.machineClientHint': 'Use client_credentials grant — no redirect URIs needed. The token is issued directly via client_id + client_secret and acts as you within the selected scopes.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Get a token: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret. No browser, no refresh token.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'About',
|
||||
'settings.about.reportBug': 'Report a Bug',
|
||||
@@ -2111,8 +2115,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||
'journey.editor.uploadPhotos': 'Upload photos',
|
||||
'journey.editor.uploading': 'Uploading...',
|
||||
'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
|
||||
'journey.editor.fromGallery': 'From Gallery',
|
||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||
'journey.editor.writeStory': 'Write your story...',
|
||||
@@ -2219,6 +2226,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Failed to delete',
|
||||
'journey.entries.deleteTitle': 'Delete Entry',
|
||||
'journey.photosUploaded': '{count} photos uploaded',
|
||||
'journey.photosUploadFailed': 'Some photos failed to upload',
|
||||
'journey.photosAdded': '{count} photos added',
|
||||
|
||||
// Journey — Public Page
|
||||
|
||||
@@ -326,6 +326,10 @@ const es: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
||||
'settings.oauth.modal.machineClient': 'Cliente de máquina (sin inicio de sesión en el navegador)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
|
||||
'settings.oauth.badge.machine': 'máquina',
|
||||
'settings.account': 'Cuenta',
|
||||
'settings.about': 'Acerca de',
|
||||
'settings.about.reportBug': 'Reportar un error',
|
||||
@@ -2084,8 +2088,11 @@ const es: Record<string, string> = {
|
||||
'journey.synced.places': 'lugares',
|
||||
'journey.synced.synced': 'sincronizado',
|
||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||
'journey.editor.uploading': 'Subiendo...',
|
||||
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||
'journey.editor.fromGallery': 'Desde galería',
|
||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||
@@ -2176,6 +2183,7 @@ const es: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||
'journey.photosUploaded': '{count} fotos subidas',
|
||||
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||
'journey.photosAdded': '{count} fotos añadidas',
|
||||
'journey.public.notFound': 'No encontrado',
|
||||
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
||||
|
||||
@@ -325,6 +325,10 @@ const fr: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Session révoquée',
|
||||
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
||||
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
||||
'settings.oauth.modal.machineClient': 'Client machine (sans connexion navigateur)',
|
||||
'settings.oauth.modal.machineClientHint': 'Utilise le grant client_credentials — aucune URI de redirection requise. Le token est émis directement via client_id + client_secret et agit en votre nom dans les portées sélectionnées.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Obtenir un token : POST /oauth/token avec grant_type=client_credentials, client_id et client_secret. Sans navigateur, sans token de rafraîchissement.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Compte',
|
||||
'settings.about': 'À propos',
|
||||
'settings.about.reportBug': 'Signaler un bug',
|
||||
@@ -2078,8 +2082,11 @@ const fr: Record<string, string> = {
|
||||
'journey.synced.places': 'lieux',
|
||||
'journey.synced.synced': 'synchronisé',
|
||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||
'journey.editor.uploading': 'Envoi...',
|
||||
'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
|
||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||
@@ -2170,6 +2177,7 @@ const fr: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Échec de la suppression',
|
||||
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
||||
'journey.photosUploaded': '{count} photos téléversées',
|
||||
'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées",
|
||||
'journey.photosAdded': '{count} photos ajoutées',
|
||||
'journey.public.notFound': 'Introuvable',
|
||||
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
||||
|
||||
@@ -280,6 +280,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
||||
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
||||
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
||||
'settings.oauth.modal.machineClient': 'Gépi kliens (böngészős bejelentkezés nélkül)',
|
||||
'settings.oauth.modal.machineClientHint': 'client_credentials grant használata — nincs szükség átirányítási URI-kra. A token közvetlenül client_id + client_secret segítségével kerül kiállításra, és a kiválasztott hatókörökön belül az Ön nevében jár el.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Token lekérése: POST /oauth/token a grant_type=client_credentials, client_id és client_secret értékekkel. Böngésző és frissítési token nélkül.',
|
||||
'settings.oauth.badge.machine': 'gépi',
|
||||
'settings.account': 'Fiók',
|
||||
'settings.about': 'Névjegy',
|
||||
'settings.about.reportBug': 'Hiba bejelentése',
|
||||
@@ -2079,8 +2083,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'helyszín',
|
||||
'journey.synced.synced': 'szinkronizálva',
|
||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||
'journey.editor.uploading': 'Feltöltés...',
|
||||
'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
|
||||
'journey.editor.fromGallery': 'Galériából',
|
||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||
@@ -2171,6 +2178,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
||||
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
||||
'journey.photosUploaded': '{count} fotó feltöltve',
|
||||
'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni',
|
||||
'journey.photosAdded': '{count} fotó hozzáadva',
|
||||
'journey.public.notFound': 'Nem található',
|
||||
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
||||
|
||||
@@ -387,6 +387,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
||||
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
||||
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
||||
'settings.oauth.modal.machineClient': 'Klien mesin (tanpa login browser)',
|
||||
'settings.oauth.modal.machineClientHint': 'Menggunakan grant client_credentials — tidak perlu URI pengalihan. Token diterbitkan langsung melalui client_id + client_secret dan bertindak sebagai Anda dalam cakupan yang dipilih.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Dapatkan token: POST /oauth/token dengan grant_type=client_credentials, client_id, dan client_secret. Tanpa browser, tanpa refresh token.',
|
||||
'settings.oauth.badge.machine': 'mesin',
|
||||
'settings.account': 'Akun',
|
||||
'settings.about': 'Tentang',
|
||||
'settings.about.reportBug': 'Laporkan Bug',
|
||||
@@ -2094,8 +2098,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Journey Entry Editor
|
||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||
'journey.editor.uploading': 'Mengunggah...',
|
||||
'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
|
||||
'journey.editor.fromGallery': 'Dari Galeri',
|
||||
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||
@@ -2198,6 +2205,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Gagal menghapus',
|
||||
'journey.entries.deleteTitle': 'Hapus Entri',
|
||||
'journey.photosUploaded': '{count} foto diunggah',
|
||||
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
|
||||
'journey.photosAdded': '{count} foto ditambahkan',
|
||||
|
||||
// Journey — Public Page
|
||||
|
||||
@@ -280,6 +280,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sessione revocata',
|
||||
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
||||
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
||||
'settings.oauth.modal.machineClient': 'Client macchina (senza login nel browser)',
|
||||
'settings.oauth.modal.machineClientHint': 'Usa il grant client_credentials — nessun URI di reindirizzamento necessario. Il token viene emesso direttamente tramite client_id + client_secret e agisce come te negli ambiti selezionati.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Ottieni token: POST /oauth/token con grant_type=client_credentials, client_id e client_secret. Senza browser, senza token di aggiornamento.',
|
||||
'settings.oauth.badge.machine': 'macchina',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Informazioni',
|
||||
'settings.about.reportBug': 'Segnala un bug',
|
||||
@@ -2079,8 +2083,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'luoghi',
|
||||
'journey.synced.synced': 'sincronizzato',
|
||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||
'journey.editor.uploadPhotos': 'Carica foto',
|
||||
'journey.editor.uploading': 'Caricamento...',
|
||||
'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
|
||||
'journey.editor.fromGallery': 'Dalla galleria',
|
||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||
@@ -2171,6 +2178,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
||||
'journey.entries.deleteTitle': 'Elimina voce',
|
||||
'journey.photosUploaded': '{count} foto caricate',
|
||||
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
|
||||
'journey.photosAdded': '{count} foto aggiunte',
|
||||
'journey.public.notFound': 'Non trovato',
|
||||
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,10 @@ const nl: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
||||
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
||||
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
||||
'settings.oauth.modal.machineClient': 'Machineclient (zonder browserinlog)',
|
||||
'settings.oauth.modal.machineClientHint': "Gebruikt de client_credentials grant — geen redirect-URI's nodig. Het token wordt direct verstrekt via client_id + client_secret en handelt namens jou binnen de geselecteerde scopes.",
|
||||
'settings.oauth.modal.machineClientUsage': 'Token ophalen: POST /oauth/token met grant_type=client_credentials, client_id en client_secret. Geen browser, geen vernieuwingstoken.',
|
||||
'settings.oauth.badge.machine': 'machine',
|
||||
'settings.account': 'Account',
|
||||
'settings.about': 'Over',
|
||||
'settings.about.reportBug': 'Bug melden',
|
||||
@@ -2078,8 +2082,11 @@ const nl: Record<string, string> = {
|
||||
'journey.synced.places': 'plaatsen',
|
||||
'journey.synced.synced': 'gesynchroniseerd',
|
||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||
'journey.editor.uploading': 'Uploaden...',
|
||||
'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
|
||||
'journey.editor.fromGallery': 'Uit galerij',
|
||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||
@@ -2170,6 +2177,7 @@ const nl: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
||||
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
||||
'journey.photosUploaded': "{count} foto's geüpload",
|
||||
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
|
||||
'journey.photosAdded': "{count} foto's toegevoegd",
|
||||
'journey.public.notFound': 'Niet gevonden',
|
||||
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
||||
|
||||
@@ -295,6 +295,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
||||
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
||||
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
||||
'settings.oauth.modal.machineClient': 'Klient maszynowy (bez logowania przez przeglądarkę)',
|
||||
'settings.oauth.modal.machineClientHint': 'Używa grantu client_credentials — nie są potrzebne URI przekierowania. Token jest wystawiany bezpośrednio przez client_id + client_secret i działa w Twoim imieniu w ramach wybranych zakresów.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Pobierz token: POST /oauth/token z grant_type=client_credentials, client_id i client_secret. Bez przeglądarki, bez tokenu odświeżania.',
|
||||
'settings.oauth.badge.machine': 'maszynowy',
|
||||
'settings.account': 'Konto',
|
||||
'settings.about': 'O aplikacji',
|
||||
'settings.about.reportBug': 'Zgłoś błąd',
|
||||
@@ -2071,8 +2075,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.synced.places': 'miejsca',
|
||||
'journey.synced.synced': 'zsynchronizowane',
|
||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||
'journey.editor.uploading': 'Przesyłanie...',
|
||||
'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
|
||||
'journey.editor.fromGallery': 'Z galerii',
|
||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||
@@ -2163,6 +2170,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
||||
'journey.entries.deleteTitle': 'Usuń wpis',
|
||||
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
||||
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
|
||||
'journey.photosAdded': '{count} zdjęć dodanych',
|
||||
'journey.public.notFound': 'Nie znaleziono',
|
||||
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
||||
|
||||
@@ -325,6 +325,10 @@ const ru: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
||||
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
||||
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
||||
'settings.oauth.modal.machineClient': 'Машинный клиент (без входа через браузер)',
|
||||
'settings.oauth.modal.machineClientHint': 'Использует грант client_credentials — URI перенаправления не требуются. Токен выдаётся напрямую через client_id + client_secret и действует от вашего имени в пределах выбранных областей.',
|
||||
'settings.oauth.modal.machineClientUsage': 'Получить токен: POST /oauth/token с grant_type=client_credentials, client_id и client_secret. Без браузера, без токена обновления.',
|
||||
'settings.oauth.badge.machine': 'машинный',
|
||||
'settings.account': 'Аккаунт',
|
||||
'settings.about': 'О приложении',
|
||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||
@@ -2078,8 +2082,11 @@ const ru: Record<string, string> = {
|
||||
'journey.synced.places': 'мест',
|
||||
'journey.synced.synced': 'синхронизировано',
|
||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||
'journey.editor.uploading': 'Загрузка...',
|
||||
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||
'journey.editor.fromGallery': 'Из галереи',
|
||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||
@@ -2170,6 +2177,7 @@ const ru: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': 'Не удалось удалить',
|
||||
'journey.entries.deleteTitle': 'Удалить запись',
|
||||
'journey.photosUploaded': '{count} фото загружено',
|
||||
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
|
||||
'journey.photosAdded': '{count} фото добавлено',
|
||||
'journey.public.notFound': 'Не найдено',
|
||||
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
||||
|
||||
@@ -325,6 +325,10 @@ const zh: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': '会话已撤销',
|
||||
'settings.oauth.toast.revokeError': '撤销会话失败',
|
||||
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
||||
'settings.oauth.modal.machineClient': '机器客户端(无需浏览器登录)',
|
||||
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授权——无需重定向 URI。令牌通过 client_id + client_secret 直接颁发,并在所选范围内以您的身份运行。',
|
||||
'settings.oauth.modal.machineClientUsage': '获取令牌:向 /oauth/token 发送 POST 请求,携带 grant_type=client_credentials、client_id 和 client_secret。无需浏览器,无刷新令牌。',
|
||||
'settings.oauth.badge.machine': '机器',
|
||||
'settings.account': '账户',
|
||||
'settings.about': '关于',
|
||||
'settings.about.reportBug': '报告错误',
|
||||
@@ -2078,8 +2082,11 @@ const zh: Record<string, string> = {
|
||||
'journey.synced.places': '个地点',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||
'journey.editor.uploadFailed': '照片上传失败',
|
||||
'journey.editor.uploadPhotos': '上传照片',
|
||||
'journey.editor.uploading': '上传中...',
|
||||
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||
'journey.editor.fromGallery': '从相册',
|
||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||
'journey.editor.writeStory': '写下你的故事...',
|
||||
@@ -2170,6 +2177,7 @@ const zh: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '删除失败',
|
||||
'journey.entries.deleteTitle': '删除条目',
|
||||
'journey.photosUploaded': '{count} 张照片已上传',
|
||||
'journey.photosUploadFailed': '部分照片上传失败',
|
||||
'journey.photosAdded': '{count} 张照片已添加',
|
||||
'journey.public.notFound': '未找到',
|
||||
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
||||
|
||||
@@ -384,6 +384,10 @@ const zhTw: Record<string, string> = {
|
||||
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
||||
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
||||
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
||||
'settings.oauth.modal.machineClient': '機器客戶端(無需瀏覽器登入)',
|
||||
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授權——無需重新導向 URI。令牌透過 client_id + client_secret 直接簽發,並在所選範圍內以您的身份運行。',
|
||||
'settings.oauth.modal.machineClientUsage': '取得令牌:向 /oauth/token 發送 POST 請求,攜帶 grant_type=client_credentials、client_id 和 client_secret。無需瀏覽器,無重整令牌。',
|
||||
'settings.oauth.badge.machine': '機器',
|
||||
'settings.account': '賬戶',
|
||||
'settings.about': '關於',
|
||||
'settings.about.reportBug': '回報錯誤',
|
||||
@@ -2036,8 +2040,11 @@ const zhTw: Record<string, string> = {
|
||||
'journey.synced.places': '個地點',
|
||||
'journey.synced.synced': '已同步',
|
||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||
'journey.editor.uploadPhotos': '上傳照片',
|
||||
'journey.editor.uploading': '上傳中...',
|
||||
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||
'journey.editor.fromGallery': '從相簿',
|
||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||
'journey.editor.writeStory': '寫下你的故事...',
|
||||
@@ -2128,6 +2135,7 @@ const zhTw: Record<string, string> = {
|
||||
'journey.settings.failedToDelete': '刪除失敗',
|
||||
'journey.entries.deleteTitle': '刪除條目',
|
||||
'journey.photosUploaded': '{count} 張照片已上傳',
|
||||
'journey.photosUploadFailed': '部分照片上傳失敗',
|
||||
'journey.photosAdded': '{count} 張照片已新增',
|
||||
'journey.public.notFound': '未找到',
|
||||
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useIsMobile } from '../hooks/useIsMobile'
|
||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const GRADIENTS = [
|
||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||
@@ -746,8 +749,8 @@ export default function JourneyDetailPage() {
|
||||
}
|
||||
return entryId
|
||||
}}
|
||||
onUploadPhotos={async (entryId, formData) => {
|
||||
return await uploadPhotos(entryId, formData)
|
||||
onUploadPhotos={async (entryId, files, cbs) => {
|
||||
return await uploadPhotos(entryId, files, cbs)
|
||||
}}
|
||||
onDone={() => {
|
||||
setEditingEntry(null)
|
||||
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files?.length) return
|
||||
setGalleryUploading(true)
|
||||
setGalleryProgress({ done: 0, total: files.length })
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const f of files) formData.append('photos', f)
|
||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||
} else {
|
||||
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||
}
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.coverFailed'))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||
} finally {
|
||||
setGalleryUploading(false)
|
||||
setGalleryProgress(null)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||
>
|
||||
{galleryUploading ? (
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||
) : (
|
||||
<><Plus size={12} /> {t('common.upload')}</>
|
||||
)}
|
||||
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
@@ -2191,7 +2201,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||
@@ -2244,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
})
|
||||
// upload queued files after entry is created
|
||||
if (pendingFiles.length > 0 && entryId) {
|
||||
const formData = new FormData()
|
||||
for (const f of pendingFiles) formData.append('photos', f)
|
||||
await onUploadPhotos(entryId, formData)
|
||||
const filesToUpload = pendingFiles
|
||||
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||
try {
|
||||
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||
})
|
||||
setPendingFiles(failed)
|
||||
if (failed.length > 0) {
|
||||
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||
} finally {
|
||||
setUploadProgress(null)
|
||||
}
|
||||
}
|
||||
// link gallery photos that were picked before save
|
||||
if (pendingLinkIds.length > 0 && entryId) {
|
||||
@@ -2265,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
if (!files?.length) return
|
||||
// Queue files locally until Save so cancel/close actually discards. This
|
||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
||||
const normalized = await normalizeImageFiles(files)
|
||||
setPendingFiles(prev => [...prev, ...normalized])
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2300,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
disabled={saving}
|
||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
||||
{uploadProgress ? (
|
||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||
) : (
|
||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||
)}
|
||||
|
||||
@@ -11,8 +11,9 @@ import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||
import { splitReservationDateTime } from '../utils/formatters'
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
|
||||
function createMarkerIcon(place: any) {
|
||||
@@ -184,14 +185,16 @@ export default function SharedTripPage() {
|
||||
{sortedDays.map((day: any, di: number) => {
|
||||
const da = assignments[String(day.id)] || []
|
||||
const notes = (dayNotes[String(day.id)] || [])
|
||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
||||
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||
|
||||
const merged = [
|
||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
||||
].sort((a, b) => a.k - b.k)
|
||||
const merged = getMergedItems({
|
||||
dayAssignments: da,
|
||||
dayNotes: notes,
|
||||
dayTransports: dayTransport,
|
||||
dayId: day.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||
@@ -212,12 +215,12 @@ export default function SharedTripPage() {
|
||||
|
||||
{selectedDay === day.id && merged.length > 0 && (
|
||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{merged.map((item: any, idx: number) => {
|
||||
{merged.map((item: any) => {
|
||||
if (item.type === 'transport') {
|
||||
const r = item.data
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||
let sub = ''
|
||||
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(' · ')
|
||||
@@ -274,8 +277,9 @@ export default function SharedTripPage() {
|
||||
{(reservations || []).map((r: any) => {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||
const time = rTime ?? ''
|
||||
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
return (
|
||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -1003,6 +1003,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||
collapsed={dayDetailCollapsed}
|
||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||
mobile={isMobile}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
@@ -1116,7 +1117,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { journeyApi } from '../api/client';
|
||||
import { useJourneyStore } from './journeyStore';
|
||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||
|
||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.json({ photos: [newPhoto] })
|
||||
)
|
||||
);
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(91);
|
||||
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||
// layer directly so this test exercises store state management only.
|
||||
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(91);
|
||||
expect(result.failed).toHaveLength(0);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(2);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
server.use(
|
||||
http.post('/api/journeys/entries/100/photos', () =>
|
||||
HttpResponse.error()
|
||||
)
|
||||
);
|
||||
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||
expect(result.succeeded).toHaveLength(0);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]).toBe(file);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||
const entry = buildEntry({ id: 100, photos: [] });
|
||||
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||
useJourneyStore.setState({ current: detail });
|
||||
|
||||
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||
let callCount = 0;
|
||||
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return { photos: [photo1] } as any;
|
||||
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||
});
|
||||
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||
expect(result.succeeded).toHaveLength(1);
|
||||
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||
expect(storedEntry?.photos).toHaveLength(1);
|
||||
void photo2; // referenced to avoid lint warning
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { journeyApi } from '../api/client'
|
||||
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||
|
||||
export interface Journey {
|
||||
id: number
|
||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
||||
deleteEntry: (entryId: number) => Promise<void>
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
||||
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
uploadPhotos: async (entryId, formData) => {
|
||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
||||
const photos = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
uploadPhotos: async (entryId, files, cbs) => {
|
||||
return uploadFilesResilient<JourneyPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||
const photos: JourneyPhoto[] = data.photos || []
|
||||
const gallery: GalleryPhoto[] = data.gallery || []
|
||||
set(s => {
|
||||
if (!s.current) return s
|
||||
return {
|
||||
current: {
|
||||
...s.current,
|
||||
entries: s.current.entries.map(e =>
|
||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||
),
|
||||
gallery: [...(s.current.gallery || []), ...gallery],
|
||||
},
|
||||
}
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||
})
|
||||
return photos
|
||||
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||
return uploadFilesResilient<GalleryPhoto>(
|
||||
files,
|
||||
async (file, opts) => {
|
||||
const fd = new FormData()
|
||||
fd.append('photos', file)
|
||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||
const photos: GalleryPhoto[] = data.photos || []
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||
})
|
||||
return photos
|
||||
},
|
||||
{ onProgress: cbs?.onProgress },
|
||||
)
|
||||
},
|
||||
|
||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||
|
||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
||||
accommodation_start_day_id?: number | null
|
||||
accommodation_end_day_id?: number | null
|
||||
day_plan_position?: number | null
|
||||
day_positions?: Record<number, number> | null
|
||||
metadata?: Record<string, string> | string | null
|
||||
needs_review?: number
|
||||
endpoints?: ReservationEndpoint[]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function looksLikeHeic(file: File): boolean {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||
}
|
||||
|
||||
export async function normalizeImageFile(file: File): Promise<File> {
|
||||
if (!looksLikeHeic(file)) return file
|
||||
const { isHeic, heicTo } = await import('heic-to')
|
||||
if (!(await isHeic(file))) return file
|
||||
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||
})
|
||||
|
||||
it('parses ISO datetime string', () => {
|
||||
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||
})
|
||||
|
||||
it('returns null for null/empty', () => {
|
||||
expect(parseTimeToMinutes(null)).toBeNull()
|
||||
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSpanPhase', () => {
|
||||
it('returns single when start === end', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||
})
|
||||
|
||||
it('returns start for the departure day', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||
})
|
||||
|
||||
it('returns end for the arrival day', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||
})
|
||||
|
||||
it('returns middle for days in between', () => {
|
||||
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
it('returns reservation_time on start day', () => {
|
||||
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||
})
|
||||
|
||||
it('returns reservation_end_time on end day', () => {
|
||||
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||
})
|
||||
|
||||
it('returns null for middle day', () => {
|
||||
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportForDay', () => {
|
||||
const days = [
|
||||
{ id: 1, day_number: 1 },
|
||||
{ id: 2, day_number: 2 },
|
||||
{ id: 3, day_number: 3 },
|
||||
]
|
||||
|
||||
it('excludes hotel (rendered via accommodation path)', () => {
|
||||
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes tour booking on the correct day', () => {
|
||||
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes restaurant, event, and other bookings by day_id', () => {
|
||||
const reservations = [
|
||||
{ id: 30, type: 'restaurant', day_id: 2 },
|
||||
{ id: 31, type: 'event', day_id: 2 },
|
||||
{ id: 32, type: 'other', day_id: 2 },
|
||||
]
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes single-day transport on the correct day', () => {
|
||||
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes multi-day transport on all spanned days', () => {
|
||||
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('excludes transport linked to an assignment on that day', () => {
|
||||
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMergedItems', () => {
|
||||
it('merges places and notes sorted by sortKey', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||
]
|
||||
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||
expect(result[0].data.id).toBe(1)
|
||||
expect(result[1].data.id).toBe(10)
|
||||
expect(result[2].data.id).toBe(2)
|
||||
})
|
||||
|
||||
it('inserts transport by time when no per-day position is set', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
|
||||
it('per-day position overrides time-based insertion', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
// Transport at 10:30 would normally go between the two places
|
||||
// but per-day position 1.5 puts it after the second place
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
expect(types).toEqual(['place', 'place', 'transport'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
export interface MergedItem {
|
||||
type: 'place' | 'note' | 'transport'
|
||||
sortKey: number
|
||||
data: any
|
||||
}
|
||||
|
||||
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||
if (!time) return null
|
||||
if (time.includes('T')) {
|
||||
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const parts = time.split(':').map(Number)
|
||||
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||
return null
|
||||
}
|
||||
|
||||
export function getSpanPhase(
|
||||
r: { day_id?: number | null; end_day_id?: number | null },
|
||||
dayId: number
|
||||
): 'single' | 'start' | 'middle' | 'end' {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
): string | null {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||
export function getTransportForDay(opts: {
|
||||
reservations: any[]
|
||||
dayId: number
|
||||
dayAssignmentIds: number[]
|
||||
days: Array<{ id: number; day_number?: number }>
|
||||
}): any[] {
|
||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||
|
||||
const getDayOrder = (id: number): number => {
|
||||
const d = days.find(x => x.id === id)
|
||||
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||
}
|
||||
const thisDayOrder = getDayOrder(dayId)
|
||||
|
||||
return reservations.filter(r => {
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startOrder = getDayOrder(startDayId)
|
||||
const endOrder = getDayOrder(endDayId)
|
||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||
export function getMergedItems(opts: {
|
||||
dayAssignments: any[]
|
||||
dayNotes: any[]
|
||||
dayTransports: any[]
|
||||
dayId: number
|
||||
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||
}): MergedItem[] {
|
||||
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||
|
||||
const baseItems: MergedItem[] = [
|
||||
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (baseItems.length === 0) {
|
||||
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||
}
|
||||
|
||||
// Insert transports among base items based on per-day position or time
|
||||
const result = [...baseItems]
|
||||
for (let ti = 0; ti < timedTransports.length; ti++) {
|
||||
const timed = timedTransports[ti]
|
||||
const minutes = timed.minutes
|
||||
|
||||
// Per-day position takes precedence (set by user reorder)
|
||||
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
|
||||
if (perDayPos != null) {
|
||||
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
|
||||
continue
|
||||
}
|
||||
|
||||
// Time-based fallback: insert after the last item whose time <= this transport's time
|
||||
let insertAfterKey = -Infinity
|
||||
for (const item of result) {
|
||||
if (item.type === 'place') {
|
||||
const pm = parseTimeToMinutes(item.data?.place?.place_time)
|
||||
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
|
||||
} else if (item.type === 'transport') {
|
||||
const tm = parseTimeToMinutes(item.data?.reservation_time)
|
||||
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
|
||||
}
|
||||
}
|
||||
|
||||
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
|
||||
const sortKey = insertAfterKey === -Infinity
|
||||
? lastKey + 0.5 + ti * 0.01
|
||||
: insertAfterKey + 0.01 + ti * 0.001
|
||||
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitReservationDateTime } from './formatters'
|
||||
|
||||
describe('splitReservationDateTime', () => {
|
||||
it('parses full ISO datetime', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses full datetime with seconds', () => {
|
||||
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses date-only string', () => {
|
||||
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||
})
|
||||
|
||||
it('parses bare HH:MM (new dateless format)', () => {
|
||||
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||
})
|
||||
|
||||
it('parses bare single-digit hour time', () => {
|
||||
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||
})
|
||||
|
||||
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||
})
|
||||
|
||||
it('returns null date for T-prefixed without valid date', () => {
|
||||
const result = splitReservationDateTime('T23:59')
|
||||
expect(result.date).toBeNull()
|
||||
expect(result.time).toBe('23:59')
|
||||
})
|
||||
|
||||
it('returns nulls for null input', () => {
|
||||
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for undefined input', () => {
|
||||
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for empty string', () => {
|
||||
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||
})
|
||||
|
||||
it('returns nulls for unrecognized string', () => {
|
||||
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||
})
|
||||
})
|
||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
||||
} catch { return timeStr }
|
||||
}
|
||||
|
||||
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||
if (!value) return { date: null, time: null }
|
||||
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||
if (value.includes('T')) {
|
||||
const [d, t] = value.split('T')
|
||||
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||
}
|
||||
if (isoDate.test(value)) return { date: value, time: null }
|
||||
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||
return { date: null, time: null }
|
||||
}
|
||||
|
||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AxiosProgressEvent } from 'axios'
|
||||
|
||||
export interface UploadProgress {
|
||||
done: number
|
||||
total: number
|
||||
failed: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ResilientResult<T> {
|
||||
succeeded: T[]
|
||||
failed: File[]
|
||||
}
|
||||
|
||||
export interface UploadOpts {
|
||||
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||
|
||||
function isRetryable(err: unknown): boolean {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const status = (err as { response?: { status?: number } }).response?.status
|
||||
if (status !== undefined && status >= 400 && status < 500) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function uploadFilesResilient<T>(
|
||||
files: File[],
|
||||
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||
cbs?: {
|
||||
concurrency?: number
|
||||
retries?: number
|
||||
onProgress?: (p: UploadProgress) => void
|
||||
onUploaded?: (items: T[]) => void
|
||||
},
|
||||
): Promise<ResilientResult<T>> {
|
||||
const concurrency = cbs?.concurrency ?? 3
|
||||
const maxRetries = cbs?.retries ?? 2
|
||||
|
||||
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||
const loadedMap = new Map<number, number>()
|
||||
let doneCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
const emitProgress = () => {
|
||||
if (!cbs?.onProgress) return
|
||||
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||
}
|
||||
|
||||
const succeeded: T[] = []
|
||||
const failedFiles: File[] = []
|
||||
|
||||
let idx = 0
|
||||
|
||||
async function worker() {
|
||||
while (true) {
|
||||
const i = idx++
|
||||
if (i >= files.length) break
|
||||
const file = files[i]
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
loadedMap.set(i, 0)
|
||||
|
||||
let items: T[] | null = null
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) await sleep(400 * attempt)
|
||||
try {
|
||||
items = await uploadOne(file, {
|
||||
idempotencyKey,
|
||||
onUploadProgress: (e) => {
|
||||
loadedMap.set(i, e.loaded)
|
||||
emitProgress()
|
||||
},
|
||||
})
|
||||
break
|
||||
} catch (err) {
|
||||
if (!isRetryable(err) || attempt === maxRetries) {
|
||||
items = null
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items !== null) {
|
||||
succeeded.push(...items)
|
||||
cbs?.onUploaded?.(items)
|
||||
loadedMap.set(i, file.size)
|
||||
doneCount++
|
||||
} else {
|
||||
failedFiles.push(file)
|
||||
loadedMap.set(i, 0)
|
||||
failedCount++
|
||||
}
|
||||
emitProgress()
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return { succeeded, failed: failedFiles }
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
|
||||
describe('SUPPORTED_LANGUAGES', () => {
|
||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
|
||||
expect(SUPPORTED_LANGUAGES).toHaveLength(16)
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
|
||||
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||
|
||||
describe('@trek/shared resolves in the client toolchain', () => {
|
||||
it('imports and uses a shared schema', () => {
|
||||
expect(idParamSchema.parse('7')).toBe(7);
|
||||
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@trek/shared": ["../shared/src/index.ts"],
|
||||
"@trek/shared/*": ["../shared/src/*"]
|
||||
},
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
+11
-1
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -88,9 +89,18 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// @trek/shared — Zod contract package (dev: resolved to TS source).
|
||||
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
|
||||
},
|
||||
// @trek/shared imports zod from its own source; it lives outside this root,
|
||||
// so pin zod to the client's copy (one instance, resolvable from anywhere).
|
||||
dedupe: ['zod'],
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
modulePreload: { polyfill: false },
|
||||
modulePreload: { polyfill: true },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// @trek/shared — Zod contract package (tests resolve it to TS source,
|
||||
// mirroring the alias in vite.config.js used by the dev server / build).
|
||||
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
|
||||
},
|
||||
// Mirror vite.config.js: keep a single zod instance resolvable from the
|
||||
// shared source, which lives outside this project root.
|
||||
dedupe: ['zod'],
|
||||
},
|
||||
test: {
|
||||
root: '.',
|
||||
globals: true,
|
||||
|
||||
Generated
+1283
-1110
File diff suppressed because it is too large
Load Diff
+26
-6
@@ -1,19 +1,27 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.16",
|
||||
"version": "3.0.22",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "node scripts/build.mjs",
|
||||
"start:prod": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:ws": "vitest run tests/websocket",
|
||||
"test:parity": "vitest run tests/parity",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -30,20 +38,30 @@
|
||||
"nodemailer": "^8.0.5",
|
||||
"otplib": "^12.0.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
"unzipper": "^0.12.3",
|
||||
"uuid": "^14.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.12",
|
||||
"@hono/node-server": "^1.19.13"
|
||||
"hono": "^4.12.16",
|
||||
"@hono/node-server": "^1.19.13",
|
||||
"picomatch": "^4.0.4",
|
||||
"ip-address": "^10.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"ws": "^8.21.0",
|
||||
"qs": "^6.15.2",
|
||||
"file-type": "^21.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -64,7 +82,9 @@
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"nodemon": "^3.1.0",
|
||||
"supertest": "^7.2.2",
|
||||
"tsc-alias": "^1.8.17",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
// tsc emits JS even with type errors (noEmitOnError:false), but still exits
|
||||
// non-zero to report them. We must run tsc-alias regardless, so run tsc in a
|
||||
// try/catch and always proceed to the path-rewrite step.
|
||||
// Type correctness is enforced separately via `npm run typecheck`.
|
||||
try {
|
||||
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
|
||||
} catch {
|
||||
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
|
||||
}
|
||||
|
||||
execSync('tsc-alias -p tsconfig.build.json', { stdio: 'inherit' });
|
||||
console.log('[build] dist ready (path aliases rewritten).');
|
||||
@@ -0,0 +1,22 @@
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
|
||||
// Dev runtime for the co-hosted NestJS + legacy Express server.
|
||||
// NestJS DI needs decorator metadata, which the old tsx/esbuild runtime does not
|
||||
// emit — so dev runs the tsc build with watchers (same toolchain as prod `dist`).
|
||||
// Initial build first so `node --watch dist/index.js` has something to start.
|
||||
console.log('[dev] initial build...');
|
||||
execSync('node scripts/build.mjs', { stdio: 'inherit' });
|
||||
|
||||
const watchers = [
|
||||
['npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput']],
|
||||
['npx', ['tsc-alias', '-w', '-p', 'tsconfig.build.json']],
|
||||
['node', ['--watch', 'dist/index.js']],
|
||||
];
|
||||
|
||||
const children = watchers.map(([cmd, args]) =>
|
||||
spawn(cmd, args, { stdio: 'inherit', shell: true }),
|
||||
);
|
||||
|
||||
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
+15
-13
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import multer from 'multer';
|
||||
import { logDebug, logWarn, logError } from './services/auditLog';
|
||||
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
||||
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
||||
@@ -25,7 +26,6 @@ import airportsRoutes from './routes/airports';
|
||||
import filesRoutes from './routes/files';
|
||||
import reservationsRoutes from './routes/reservations';
|
||||
import dayNotesRoutes from './routes/dayNotes';
|
||||
import weatherRoutes from './routes/weather';
|
||||
import settingsRoutes from './routes/settings';
|
||||
import budgetRoutes from './routes/budget';
|
||||
import collabRoutes from './routes/collab';
|
||||
@@ -50,11 +50,11 @@ import { getCollabFeatures } from './services/adminService';
|
||||
import { isAddonEnabled } from './services/adminService';
|
||||
import { ADDON_IDS } from './addons';
|
||||
import { ALL_SCOPES } from './mcp/scopes';
|
||||
import { getAppUrl } from './services/oidcService';
|
||||
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
|
||||
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
|
||||
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
|
||||
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
|
||||
import { getMcpSafeUrl } from './services/notifications';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
@@ -360,7 +360,8 @@ export function createApp(): express.Application {
|
||||
app.use('/api/photos', photoRoutes);
|
||||
app.use('/api/maps', mapsRoutes);
|
||||
app.use('/api/airports', airportsRoutes);
|
||||
app.use('/api/weather', weatherRoutes);
|
||||
// /api/weather is served by the NestJS weather module (see src/nest/weather);
|
||||
// the legacy Express route was decommissioned after the migration (L1).
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/system-notices', systemNoticesRoutes);
|
||||
app.use('/api/backup', backupRoutes);
|
||||
@@ -388,7 +389,7 @@ export function createApp(): express.Application {
|
||||
|
||||
function getOAuthMetadata(): OAuthMetadata {
|
||||
if (_oauthMetadata) return _oauthMetadata;
|
||||
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
_oauthMetadata = {
|
||||
issuer: base,
|
||||
authorization_endpoint: `${base}/oauth/authorize`,
|
||||
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
|
||||
revocation_endpoint: `${base}/oauth/revoke`,
|
||||
registration_endpoint: `${base}/oauth/register`,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
scopes_supported: ALL_SCOPES,
|
||||
@@ -416,14 +417,11 @@ export function createApp(): express.Application {
|
||||
return _sdkMetaRouter;
|
||||
}
|
||||
|
||||
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
|
||||
// so static files and SPA routes are unaffected when MCP is off.
|
||||
// Only invoke the SDK metadata router for /.well-known/* paths.
|
||||
// Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which
|
||||
// throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads.
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const isMetadataPath =
|
||||
req.path === '/.well-known/oauth-authorization-server' ||
|
||||
req.path === '/.well-known/openid-configuration' ||
|
||||
req.path.startsWith('/.well-known/oauth-protected-resource');
|
||||
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
|
||||
getMetaRouter()(req, res, next);
|
||||
});
|
||||
|
||||
@@ -510,6 +508,10 @@ export function createApp(): express.Application {
|
||||
} else {
|
||||
console.error('Unhandled error:', err);
|
||||
}
|
||||
if (err instanceof multer.MulterError) {
|
||||
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
||||
return res.status(status).json({ error: err.message });
|
||||
}
|
||||
const status = err.statusCode || err.status || 500;
|
||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
||||
const message = status < 500 ? err.message : 'Internal server error';
|
||||
|
||||
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
|
||||
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
|
||||
},
|
||||
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
|
||||
// clients to skip the browser consent flow entirely and obtain tokens directly
|
||||
// via client_id + client_secret. Flag is immutable after creation so existing
|
||||
// authorization-code clients are not silently upgraded.
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
|
||||
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Drop stale atlas cache rows for territories that used to resolve to their
|
||||
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
|
||||
// etc.) before their own bounding boxes existed. The next atlas stats request
|
||||
// re-resolves any place inside these boxes with the corrected country code.
|
||||
() => {
|
||||
const enclaveBoxes: [number, number, number, number][] = [
|
||||
[113.83, 22.15, 114.43, 22.56], // HK
|
||||
[113.53, 22.10, 113.60, 22.21], // MO
|
||||
[12.40, 43.89, 12.52, 43.99], // SM
|
||||
[12.44, 41.90, 12.46, 41.91], // VA
|
||||
[7.40, 43.72, 7.44, 43.75], // MC
|
||||
[9.47, 47.05, 9.64, 47.27], // LI
|
||||
[-5.36, 36.11, -5.33, 36.16], // GI
|
||||
[-67.30, 17.88, -65.22, 18.53], // PR
|
||||
];
|
||||
try {
|
||||
const del = db.prepare(
|
||||
`DELETE FROM place_regions WHERE place_id IN (
|
||||
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
|
||||
)`
|
||||
);
|
||||
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
|
||||
del.run(minLat, maxLat, minLng, maxLng);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('no such table')) throw err;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
+87
-15
@@ -1,7 +1,16 @@
|
||||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ExpressAdapter } from '@nestjs/platform-express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { createApp } from './app';
|
||||
import { AppModule } from './nest/app.module';
|
||||
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
|
||||
|
||||
// Create upload and data directories on startup
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
@@ -16,9 +25,13 @@ const tmpDir = path.join(__dirname, '../data/tmp');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
});
|
||||
|
||||
const app = createApp();
|
||||
// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
|
||||
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
|
||||
// everything else falls through to this app via a fallback middleware.
|
||||
const legacyApp = createApp();
|
||||
|
||||
import * as scheduler from './scheduler';
|
||||
import { getAppUrl, getMcpSafeUrl } from './services/notifications';
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001;
|
||||
const HOST = process.env.HOST;
|
||||
@@ -29,22 +42,47 @@ const onListen = () => {
|
||||
const LOG_LVL = (process.env.LOG_LEVEL || 'info').toLowerCase();
|
||||
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const origins = process.env.ALLOWED_ORIGINS || '(same-origin)';
|
||||
const appUrl = getAppUrl();
|
||||
const resolvedAppUrl = getMcpSafeUrl();
|
||||
const banner = [
|
||||
'──────────────────────────────────────',
|
||||
' TREK API started',
|
||||
` Version ${APP_VERSION}`,
|
||||
...(HOST ? [` Host: ${HOST}`] : []),
|
||||
` Port: ${PORT}`,
|
||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
` Log file: /app/data/logs/trek.log`,
|
||||
` PID: ${process.pid}`,
|
||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||
` Version ${APP_VERSION}`,
|
||||
...(HOST ? [` Host: ${HOST}`] : []),
|
||||
` Container Port: ${PORT}`,
|
||||
` App URL: ${appUrl}`,
|
||||
` Environment: ${process.env.NODE_ENV?.toLowerCase() || 'development'}`,
|
||||
` Timezone: ${tz}`,
|
||||
` Origins: ${origins}`,
|
||||
` Log level: ${LOG_LVL}`,
|
||||
` Log file: /app/data/logs/trek.log`,
|
||||
` PID: ${process.pid}`,
|
||||
` User: uid=${process.getuid?.()} gid=${process.getgid?.()}`,
|
||||
'──────────────────────────────────────',
|
||||
];
|
||||
banner.forEach(l => console.log(l));
|
||||
sLogInfo(
|
||||
NEST_PREFIXES.length
|
||||
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
|
||||
: 'NestJS prefixes: none — all routes served by the legacy Express app',
|
||||
);
|
||||
if (process.env.APP_URL) {
|
||||
let parsedAppUrl: URL | null = null;
|
||||
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
|
||||
|
||||
if (!parsedAppUrl) {
|
||||
sLogWarn(`APP_URL: "${process.env.APP_URL}" is not a valid URL — it will be ignored.`);
|
||||
}
|
||||
|
||||
const mcpSafe = parsedAppUrl !== null && (
|
||||
parsedAppUrl.protocol === 'https:' ||
|
||||
parsedAppUrl.hostname === 'localhost' ||
|
||||
parsedAppUrl.hostname === '127.0.0.1'
|
||||
);
|
||||
if (!mcpSafe) {
|
||||
sLogWarn(`APP_URL: not MCP-safe (requires https:// or http://localhost) — MCP will use ${resolvedAppUrl}.`);
|
||||
}
|
||||
}
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') sLogInfo('Demo mode: ENABLED');
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && process.env.NODE_ENV?.toLowerCase() === 'production') {
|
||||
sLogWarn('SECURITY WARNING: DEMO_MODE is enabled in production!');
|
||||
@@ -63,9 +101,42 @@ const onListen = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const server = HOST
|
||||
? app.listen(PORT, HOST, onListen)
|
||||
: app.listen(PORT, onListen);
|
||||
let server: http.Server;
|
||||
let nestApp: INestApplication;
|
||||
|
||||
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
|
||||
const NEST_PREFIXES = getNestPrefixes();
|
||||
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
// Nest runs on its own Express instance (bodyParser off so request bodies reach
|
||||
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
|
||||
// Nest body parsing is safe here: the dispatcher only forwards migrated
|
||||
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
|
||||
// is reached separately and never passes through Nest's parser.
|
||||
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
|
||||
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
|
||||
nestApp.use(cookieParser());
|
||||
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
|
||||
await nestApp.init();
|
||||
const nestInstance = nestApp.getHttpAdapter().getInstance();
|
||||
|
||||
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
|
||||
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
|
||||
// only applies within migrated prefixes.
|
||||
const top = express();
|
||||
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
|
||||
top.use(legacyApp);
|
||||
|
||||
server = http.createServer(top);
|
||||
if (HOST) server.listen(PORT, HOST, onListen);
|
||||
else server.listen(PORT, onListen);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Fatal: failed to bootstrap server', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal: string): void {
|
||||
@@ -74,6 +145,7 @@ function shutdown(signal: string): void {
|
||||
sLogInfo(`${signal} received — shutting down gracefully...`);
|
||||
scheduler.stop();
|
||||
closeMcpSessions();
|
||||
void nestApp?.close();
|
||||
server.close(() => {
|
||||
sLogInfo('HTTP server closed');
|
||||
const { closeDb } = require('./db/database');
|
||||
@@ -90,4 +162,4 @@ function shutdown(signal: string): void {
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
export default app;
|
||||
export default legacyApp;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { registerResources } from './resources';
|
||||
import { registerTools } from './tools';
|
||||
import { McpSession, sessions, revokeUserSessions, revokeUserSessionsForClient } from './sessionManager';
|
||||
import { writeAudit, getClientIp } from '../services/auditLog';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
export { revokeUserSessions, revokeUserSessionsForClient };
|
||||
|
||||
@@ -153,7 +153,7 @@ const sessionSweepInterval = setInterval(() => {
|
||||
sessionSweepInterval.unref();
|
||||
|
||||
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
|
||||
const base = (getAppUrl() || '').replace(/\/+$/, '');
|
||||
const base = (getMcpSafeUrl() || '').replace(/\/+$/, '');
|
||||
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
|
||||
res.set('WWW-Authenticate',
|
||||
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
|
||||
@@ -183,7 +183,7 @@ function verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
if (!result) return null;
|
||||
// RFC 8707: audience must always match this resource endpoint.
|
||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||
const expected = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const expected = `${(getMcpSafeUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
if (result.audience !== expected) return null;
|
||||
return { user: result.user, scopes: result.scopes, clientId: result.clientId, isStaticToken: false };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
getUserByAccessToken,
|
||||
} from '../services/oauthService';
|
||||
import { ALL_SCOPES } from './scopes';
|
||||
import { getAppUrl } from '../services/oidcService';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
import { writeAudit } from '../services/auditLog';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,7 +125,7 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
||||
|
||||
// Redirects browser to the SPA consent page with OAuth params forwarded.
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
|
||||
|
||||
if (resource !== mcpResource) {
|
||||
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.resource) qs.set('resource', params.resource.href);
|
||||
|
||||
res.redirect(302, `/oauth/consent?${qs.toString()}`);
|
||||
const base = getMcpSafeUrl().replace(/\/+$/, '');
|
||||
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
|
||||
},
|
||||
|
||||
// Not called because skipLocalPkceValidation = true.
|
||||
|
||||
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
server.registerTool(
|
||||
'create_place_accommodation',
|
||||
{
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.',
|
||||
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
|
||||
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
if (W) server.registerTool(
|
||||
'create_place',
|
||||
{
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.',
|
||||
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
notes: z.string().max(2000).optional(),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
|
||||
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
return ok({ place });
|
||||
}
|
||||
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
if (W) server.registerTool(
|
||||
'create_and_assign_place',
|
||||
{
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.',
|
||||
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
|
||||
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
|
||||
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createReservation, getReservation, updateReservation, deleteReservation,
|
||||
updatePositions as updateReservationPositions,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
||||
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
||||
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
||||
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
||||
: undefined;
|
||||
|
||||
const metadata = price != null ? { price: String(price) } : undefined;
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, type, reservation_time, location, confirmation_number,
|
||||
notes, day_id, place_id, assignment_id,
|
||||
create_accommodation: createAccommodation,
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (accommodationCreated) {
|
||||
safeBroadcast(tripId, 'accommodation:created', {});
|
||||
}
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
} from '../../services/reservationService';
|
||||
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
server.registerTool(
|
||||
'create_transport',
|
||||
{
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.',
|
||||
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
|
||||
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
|
||||
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
if (end_day_id && !getDay(end_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
||||
|
||||
const meta: Record<string, string> = { ...(metadata ?? {}) };
|
||||
if (price != null) meta.price = String(price);
|
||||
|
||||
const { reservation } = createReservation(tripId, {
|
||||
title,
|
||||
type,
|
||||
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
|
||||
day_id: start_day_id,
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata,
|
||||
metadata: Object.keys(meta).length > 0 ? meta : undefined,
|
||||
endpoints,
|
||||
needs_review,
|
||||
});
|
||||
|
||||
if (price != null && price > 0) {
|
||||
const item = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: budget_category || type,
|
||||
total_price: price,
|
||||
});
|
||||
safeBroadcast(tripId, 'budget:created', { item });
|
||||
}
|
||||
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# NestJS migration layer — module & test guide
|
||||
|
||||
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
|
||||
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
|
||||
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
|
||||
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
|
||||
its shape when migrating a new domain.
|
||||
|
||||
## Module layout (per domain)
|
||||
|
||||
```
|
||||
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
|
||||
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
|
||||
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
|
||||
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
|
||||
```
|
||||
|
||||
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
|
||||
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
|
||||
rollback, no redeploy).
|
||||
|
||||
## Parity is law
|
||||
|
||||
A migrated route must be **byte-identical** for the client: same URL, method,
|
||||
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
|
||||
strings. Where the legacy route returns a hand-written error (e.g. weather's
|
||||
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
|
||||
the controller rather than relying on the generic `ZodValidationPipe` envelope.
|
||||
|
||||
## How to write the tests
|
||||
|
||||
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
|
||||
scoped to `src/nest/**`) requires ≥80%.
|
||||
|
||||
1. **Service / controller unit spec** — `tests/unit/nest/<domain>.controller.test.ts`.
|
||||
Instantiate the controller with a mocked service; assert status codes, the exact
|
||||
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
|
||||
See `weather.controller.test.ts`.
|
||||
|
||||
2. **Parity test** — `tests/parity/<domain>.parity.test.ts`. Mock the shared service
|
||||
identically for both apps, then fire the same request at the Express route and the
|
||||
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
|
||||
assert identical status + body. This is the gate before flipping the toggle.
|
||||
See `weather.parity.test.ts`.
|
||||
|
||||
3. **e2e** — `tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
|
||||
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
|
||||
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
|
||||
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
|
||||
(HTTP/etc.). See `weather.e2e.test.ts`.
|
||||
|
||||
## Definition of Done (per module)
|
||||
|
||||
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
|
||||
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
|
||||
Nest → parity verified on the demo DB → **then** decommission the old Express
|
||||
route/service (separate step, after the toggle is confirmed in prod) → frontend points
|
||||
at the typed contract (Frontend Track).
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { HealthService } from './health/health.service';
|
||||
import { WeatherModule } from './weather/weather.module';
|
||||
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
|
||||
/**
|
||||
* Root NestJS module for the incremental migration. Domain modules
|
||||
* (weather, notifications, ...) get registered here as they are migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, WeatherModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
// Global error-envelope normaliser (DI-registered so it also catches
|
||||
// framework-level exceptions like the not-found handler).
|
||||
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/**
|
||||
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
|
||||
* Use together with JwtAuthGuard (which populates req.user):
|
||||
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
throw new HttpException({ error: 'Admin access required' }, 403);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
|
||||
/**
|
||||
* Resolves the authenticated user attached by JwtAuthGuard.
|
||||
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): User | undefined => {
|
||||
return context.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
|
||||
|
||||
/**
|
||||
* Validates TREK's existing JWT session — the same httpOnly `trek_session`
|
||||
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
|
||||
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
|
||||
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
|
||||
*
|
||||
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
const user = verifyJwtAndLoadUser(token);
|
||||
if (!user) {
|
||||
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
|
||||
}
|
||||
(req as Request & { user?: unknown }).user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
/**
|
||||
* Normalises every Nest exception to TREK's legacy error envelope so migrated
|
||||
* routes are byte-identical for the client:
|
||||
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
|
||||
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
|
||||
* This replaces Nest's default { statusCode, message, error } body, which the
|
||||
* TREK client does not expect.
|
||||
*/
|
||||
@Catch()
|
||||
export class TrekExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const res = host.switchToHttp().getResponse<Response>();
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
|
||||
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
|
||||
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
|
||||
res.status(status).json(body);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
|
||||
const message =
|
||||
status < 500
|
||||
? Array.isArray(raw)
|
||||
? raw.join(', ')
|
||||
: String(raw ?? 'Error')
|
||||
: 'Internal server error';
|
||||
res.status(status).json({ error: message });
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown/unhandled error — mirror the legacy 500 behaviour.
|
||||
console.error('Unhandled error:', exception);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
/**
|
||||
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
|
||||
* and returns the parsed, typed value. On failure it throws TREK's error envelope
|
||||
* `{ error: string }` with status 400 — the same shape the legacy routes produce,
|
||||
* so the client's error handling is unaffected.
|
||||
*
|
||||
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZodValidationPipe implements PipeTransform {
|
||||
constructor(private readonly schema: ZodType) {}
|
||||
|
||||
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
const message = result.error.issues
|
||||
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
|
||||
.join('; ');
|
||||
throw new HttpException({ error: message }, 400);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
/**
|
||||
* Global so every migrated module can inject DatabaseService without re-importing.
|
||||
* Wraps the existing better-sqlite3 singleton (no new connection).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { db } from '../../db/database';
|
||||
|
||||
/**
|
||||
* Injectable wrapper around TREK's existing better-sqlite3 connection.
|
||||
*
|
||||
* `db` is a Proxy onto the singleton connection the legacy app already uses
|
||||
* (WAL enabled), so Nest modules share the exact same connection — no second
|
||||
* connection, no split state, single writer preserved.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
|
||||
get connection(): Database.Database {
|
||||
return db;
|
||||
}
|
||||
|
||||
prepare(sql: string): Database.Statement {
|
||||
return db.prepare(sql);
|
||||
}
|
||||
|
||||
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
|
||||
return db.prepare(sql).get(...params) as T | undefined;
|
||||
}
|
||||
|
||||
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
|
||||
return db.prepare(sql).all(...params) as T[];
|
||||
}
|
||||
|
||||
run(sql: string, ...params: unknown[]): Database.RunResult {
|
||||
return db.prepare(sql).run(...params);
|
||||
}
|
||||
|
||||
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
|
||||
transaction<T>(fn: (conn: Database.Database) => T): T {
|
||||
return db.transaction(() => fn(db))();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import type { User } from '../../types';
|
||||
import { HealthService } from './health.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
|
||||
// Local demo schema (real domains import their schema from @trek/shared).
|
||||
const echoSchema = z.object({ name: z.string().min(1) });
|
||||
|
||||
/**
|
||||
* Foundation smoke endpoints for the co-hosted NestJS app.
|
||||
* Proves: boot, routing, type-based DI, the shared SQLite connection, the
|
||||
* JWT-cookie auth guard, and the Zod validation pipe + error-envelope parity.
|
||||
*
|
||||
* Lives under /api/_nest/* so it never collides with the legacy Express API.
|
||||
*/
|
||||
@Controller('api/_nest')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get('health')
|
||||
getHealth() {
|
||||
return { ok: true, ...this.healthService.info() };
|
||||
}
|
||||
|
||||
/** Guarded: returns the authenticated user, proving JwtAuthGuard + @CurrentUser. */
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: User) {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Validated: proves the Zod pipe (400 + { error } on failure) and body parsing. */
|
||||
@Post('echo')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
echo(@Body(new ZodValidationPipe(echoSchema)) body: z.infer<typeof echoSchema>) {
|
||||
return { youSent: body };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
/**
|
||||
* Smoke service proving NestJS DI works under the chosen runtime AND that the
|
||||
* injected DatabaseService talks to TREK's existing SQLite connection.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
info() {
|
||||
const row = this.database.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
|
||||
return {
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
// Proof the shared connection works: real row count from the existing DB.
|
||||
userCount: row?.n ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Strangler toggle for the incremental NestJS migration.
|
||||
*
|
||||
* `getNestPrefixes()` returns the request path prefixes that NestJS handles;
|
||||
* every other path falls through to the legacy Express app. The default is the
|
||||
* set of prefixes whose Nest modules exist. Operators can override it at runtime
|
||||
* via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
|
||||
* rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
|
||||
* everything back to the legacy app.
|
||||
*/
|
||||
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
|
||||
|
||||
export function getNestPrefixes(): string[] {
|
||||
const raw = process.env.NEST_PREFIXES;
|
||||
if (raw !== undefined) {
|
||||
return raw.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return DEFAULT_NEST_PREFIXES;
|
||||
}
|
||||
|
||||
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
|
||||
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
|
||||
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
|
||||
import type { WeatherResult } from '@trek/shared';
|
||||
import { WeatherService } from './weather.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { ApiError } from '../../services/weatherService';
|
||||
|
||||
/**
|
||||
* /api/weather — first migrated leaf module (the pilot).
|
||||
*
|
||||
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
|
||||
* weather.ts): same paths, query params, status codes and `{ error }` bodies.
|
||||
*
|
||||
* Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
|
||||
* strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
|
||||
* rather than derived from the schema. The Zod contract/types live in
|
||||
* @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
|
||||
* the param is absent, matching the Express destructuring default.
|
||||
*/
|
||||
@Controller('api/weather')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class WeatherController {
|
||||
constructor(private readonly weather: WeatherService) {}
|
||||
|
||||
@Get()
|
||||
async getWeather(
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('date') date?: string,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<WeatherResult> {
|
||||
if (!lat || !lng) {
|
||||
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.weather.get(lat, lng, date, lang ?? 'de');
|
||||
} catch (err: unknown) {
|
||||
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('detailed')
|
||||
async getDetailed(
|
||||
@Query('lat') lat?: string,
|
||||
@Query('lng') lng?: string,
|
||||
@Query('date') date?: string,
|
||||
@Query('lang') lang?: string,
|
||||
): Promise<WeatherResult> {
|
||||
if (!lat || !lng || !date) {
|
||||
throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
|
||||
} catch (err: unknown) {
|
||||
throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
|
||||
function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
|
||||
if (err instanceof ApiError) {
|
||||
return new HttpException({ error: err.message }, err.status);
|
||||
}
|
||||
console.error(logPrefix, err);
|
||||
return new HttpException({ error: fallback }, 500);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WeatherController } from './weather.controller';
|
||||
import { WeatherService } from './weather.service';
|
||||
|
||||
/** Weather domain (pilot leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [WeatherController],
|
||||
providers: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WeatherResult } from '@trek/shared';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing weather service. It delegates to the
|
||||
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
|
||||
* the MCP tools use, so behaviour — including the shared in-memory cache and the
|
||||
* Open-Meteo calls — is identical. No logic is duplicated; the upstream service
|
||||
* stays the single source of truth (still consumed by the MCP weather tools).
|
||||
*/
|
||||
@Injectable()
|
||||
export class WeatherService {
|
||||
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
|
||||
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||
}
|
||||
|
||||
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
|
||||
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
|
||||
}
|
||||
}
|
||||
@@ -148,11 +148,16 @@ router.post('/register', authLimiter, (req: Request, res: Response) => {
|
||||
res.status(201).json({ token: result.token, user: result.user });
|
||||
});
|
||||
|
||||
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
router.post('/login', authLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
const result = loginUser(req.body);
|
||||
if (result.auditAction) {
|
||||
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
||||
}
|
||||
const elapsed = Date.now() - started;
|
||||
if (elapsed < LOGIN_MIN_LATENCY_MS) {
|
||||
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
|
||||
}
|
||||
if (result.error) return res.status(result.status!).json({ error: result.error });
|
||||
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
||||
setAuthCookie(res, result.token!, req);
|
||||
@@ -166,9 +171,10 @@ router.post('/login', authLimiter, (req: Request, res: Response) => {
|
||||
// Generic OK response — identical regardless of email existence, to
|
||||
// prevent enumeration via response body OR status code.
|
||||
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
||||
// Minimum time we spend inside the forgot handler so a "no such user"
|
||||
// path does not complete noticeably faster than a real reset.
|
||||
// Minimum time we spend inside the forgot/login handlers so a "no such
|
||||
// user" path does not complete noticeably faster than a real operation.
|
||||
const FORGOT_MIN_LATENCY_MS = 350;
|
||||
const LOGIN_MIN_LATENCY_MS = 350;
|
||||
|
||||
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
||||
const started = Date.now();
|
||||
|
||||
@@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) =
|
||||
|
||||
// ── Photos (prefix /photos and /entries — before /:id) ───────────────────
|
||||
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => {
|
||||
router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
@@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon
|
||||
// ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
|
||||
|
||||
// Upload photos directly to the journey gallery (no entry association)
|
||||
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => {
|
||||
router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
consumeAuthCode,
|
||||
saveConsent,
|
||||
issueTokens,
|
||||
issueClientCredentialsToken,
|
||||
refreshTokens,
|
||||
revokeToken,
|
||||
verifyPKCE,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
AuthorizeParams,
|
||||
} from '../services/oauthService';
|
||||
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
|
||||
import { getMcpSafeUrl } from '../services/notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal in-file rate limiter (same pattern as auth.ts)
|
||||
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
|
||||
return res.json(result.tokens);
|
||||
}
|
||||
|
||||
// ---- client_credentials grant ----
|
||||
if (grant_type === 'client_credentials') {
|
||||
if (!client_secret) {
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
}
|
||||
|
||||
const client = authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
}
|
||||
|
||||
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
|
||||
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
|
||||
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
|
||||
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
|
||||
}
|
||||
|
||||
// Scope: use requested subset or fall back to all allowed scopes.
|
||||
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
|
||||
let grantedScopes: string[];
|
||||
if (body.scope) {
|
||||
const requested = body.scope.split(' ').filter(Boolean);
|
||||
const invalid = requested.filter(s => !allowedScopes.includes(s));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
|
||||
}
|
||||
grantedScopes = requested;
|
||||
} else {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
|
||||
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
|
||||
// token passes audience binding in mcp/index.ts without extra configuration.
|
||||
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
|
||||
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
return res.json(tokens);
|
||||
}
|
||||
|
||||
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
|
||||
});
|
||||
|
||||
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
|
||||
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
|
||||
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
|
||||
const { user } = req as AuthRequest;
|
||||
const { name, redirect_uris, allowed_scopes } = req.body as {
|
||||
const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
|
||||
name: string;
|
||||
redirect_uris: string[];
|
||||
redirect_uris?: string[];
|
||||
allowed_scopes: string[];
|
||||
allows_client_credentials?: boolean;
|
||||
};
|
||||
|
||||
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
|
||||
const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
|
||||
if (result.error) return res.status(result.status || 400).json({ error: result.error });
|
||||
return res.status(201).json(result);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
touchLastLogin,
|
||||
generateToken,
|
||||
frontendUrl,
|
||||
getAppUrl,
|
||||
} from '../services/oidcService';
|
||||
import { getAppUrl } from '../services/notifications';
|
||||
import { resolveAuthToggles } from '../services/authService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
updateReservation,
|
||||
deleteReservation,
|
||||
} from '../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
|
||||
|
||||
const router = express.Router({ mergeParams: true });
|
||||
|
||||
@@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
// Auto-create budget entry if price was provided
|
||||
if (create_budget_entry && create_budget_entry.total_price > 0) {
|
||||
try {
|
||||
const budgetItem = createBudgetItem(tripId, {
|
||||
const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
|
||||
name: title,
|
||||
category: create_budget_entry.category || type || 'Other',
|
||||
total_price: create_budget_entry.total_price,
|
||||
});
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
|
||||
budgetItem.reservation_id = reservation.id;
|
||||
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
|
||||
} catch (err) {
|
||||
console.error('[reservations] Failed to create budget entry:', err);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
|
||||
|
||||
if (!lat || !lng) {
|
||||
return res.status(400).json({ error: 'Latitude and longitude are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching weather data' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
|
||||
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
|
||||
|
||||
if (!lat || !lng || !date) {
|
||||
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDetailedWeather(lat, lng, date, lang);
|
||||
res.json(result);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error('Detailed weather error:', err);
|
||||
res.status(500).json({ error: 'Error fetching detailed weather data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -100,6 +100,12 @@ export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
|
||||
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
|
||||
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
|
||||
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
|
||||
// Territories with their own ISO code that sit inside a larger country's box.
|
||||
// Listed so getCountryFromCoords()'s smallest-box match picks them over the host
|
||||
// (e.g. Hong Kong/Macau over China, San Marino/Vatican over Italy).
|
||||
HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],SM:[12.40,43.89,12.52,43.99],
|
||||
VA:[12.44,41.90,12.46,41.91],MC:[7.40,43.72,7.44,43.75],LI:[9.47,47.05,9.64,47.27],
|
||||
GI:[-5.36,36.11,-5.33,36.16],PR:[-67.30,17.88,-65.22,18.53],
|
||||
};
|
||||
|
||||
export const NAME_TO_CODE: Record<string, string> = {
|
||||
@@ -144,6 +150,9 @@ export const NAME_TO_CODE: Record<string, string> = {
|
||||
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
|
||||
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
|
||||
'somalia':'SO','papua new guinea':'PG','brunei':'BN',
|
||||
'hong kong':'HK','hong kong sar':'HK','macau':'MO','macao':'MO','macau sar':'MO',
|
||||
'san marino':'SM','vatican':'VA','vatican city':'VA','holy see':'VA','monaco':'MC',
|
||||
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
|
||||
};
|
||||
|
||||
export const CONTINENT_MAP: Record<string, string> = {
|
||||
@@ -167,6 +176,7 @@ export const CONTINENT_MAP: Record<string, string> = {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -366,11 +376,17 @@ export async function getStats(userId: number) {
|
||||
for (const place of places) {
|
||||
if (place.address) {
|
||||
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
|
||||
if (raw) {
|
||||
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
|
||||
if (city) citySet.add(city);
|
||||
// The last part is the country; the city is usually right before it, but a
|
||||
// full formatted address can have a postal code sitting between them
|
||||
// (e.g. "Bucharest, 010071, Romania"). Walk back from the country and take
|
||||
// the first part that still has letters once digits/postal noise is stripped.
|
||||
const candidates = parts.length >= 2 ? parts.slice(0, -1) : parts;
|
||||
let city = '';
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const cleaned = candidates[i].replace(/[\d\-\u2212\u3012]+/g, '').trim();
|
||||
if (cleaned) { city = cleaned.toLowerCase(); break; }
|
||||
}
|
||||
if (city) citySet.add(city);
|
||||
}
|
||||
}
|
||||
const totalCities = citySet.size;
|
||||
|
||||
@@ -26,6 +26,11 @@ import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
|
||||
authenticator.options = { window: 1 };
|
||||
|
||||
// Pre-computed bcrypt hash to equalise timing of "unknown email" and
|
||||
// "OIDC-only account" branches with the real verification path (CWE-208).
|
||||
// Cost factor 12 matches register/changePassword/resetPassword — must stay in sync.
|
||||
const DUMMY_PASSWORD_HASH = bcrypt.hashSync('__trek_no_such_user__', 12);
|
||||
|
||||
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
|
||||
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
|
||||
const MFA_BACKUP_CODE_COUNT = 10;
|
||||
@@ -437,14 +442,24 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email) as User | undefined;
|
||||
|
||||
// Always run bcrypt — even for unknown/OIDC-only users — so response time
|
||||
// does not reveal whether the email exists in the database (CWE-203/208).
|
||||
const hashToCheck = user?.password_hash ?? DUMMY_PASSWORD_HASH;
|
||||
const validPassword = bcrypt.compareSync(password, hashToCheck);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
auditUserId: null, auditAction: 'user.login_failed', auditDetails: { email, reason: 'unknown_email' },
|
||||
};
|
||||
}
|
||||
|
||||
const validPassword = bcrypt.compareSync(password, user.password_hash!);
|
||||
if (!user.password_hash) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
auditUserId: Number(user.id), auditAction: 'user.login_failed', auditDetails: { email, reason: 'oidc_only' },
|
||||
};
|
||||
}
|
||||
if (!validPassword) {
|
||||
return {
|
||||
error: 'Invalid email or password', status: 401,
|
||||
|
||||
@@ -96,6 +96,17 @@ export function createBudgetItem(
|
||||
return item;
|
||||
}
|
||||
|
||||
export function linkBudgetItemToReservation(
|
||||
tripId: string | number,
|
||||
reservationId: number,
|
||||
data: { name: string; category?: string; total_price: number },
|
||||
) {
|
||||
const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
|
||||
item.reservation_id = reservationId;
|
||||
return item;
|
||||
}
|
||||
|
||||
export function updateBudgetItem(
|
||||
id: string | number,
|
||||
tripId: string | number,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { checkSsrf } from '../utils/ssrfGuard';
|
||||
import { getAppUrl } from './notifications';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
|
||||
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
|
||||
googleApiCallCount++;
|
||||
console.debug(`[Google API] #${googleApiCallCount} ${label} → ${endpoint}`);
|
||||
return fetch(endpoint, init);
|
||||
const referer = process.env.APP_URL ? getAppUrl() : undefined;
|
||||
return fetch(endpoint, {
|
||||
...init,
|
||||
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Interfaces ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,13 +46,42 @@ function getSmtpConfig(): SmtpConfig | null {
|
||||
|
||||
// Exported for use by notificationService
|
||||
export function getAppUrl(): string {
|
||||
if (process.env.APP_URL) return process.env.APP_URL;
|
||||
if (process.env.APP_URL) {
|
||||
try {
|
||||
const _ = new URL(process.env.APP_URL);
|
||||
return process.env.APP_URL.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
}
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
const first = origins.split(',')[0]?.trim();
|
||||
if (first) return first.replace(/\/+$/, '');
|
||||
if (first) {
|
||||
try {
|
||||
const _ = new URL(first);
|
||||
return first.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const port = process.env.PORT || '3000';
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
/** Returns a URL guaranteed to satisfy the MCP SDK's issuer requirements (HTTPS or localhost).
|
||||
* Falls back to http://localhost:{PORT} when APP_URL/ALLOWED_ORIGINS use a non-HTTPS, non-localhost scheme
|
||||
* that would cause checkIssuerUrl to throw "Issuer URL must be HTTPS". */
|
||||
export function getMcpSafeUrl(): string {
|
||||
const candidate = getAppUrl();
|
||||
try {
|
||||
const u = new URL(candidate);
|
||||
if (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
|
||||
return candidate;
|
||||
}
|
||||
} catch {
|
||||
// candidate was somehow invalid — fall through to localhost
|
||||
}
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
@@ -287,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string {
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = escapeHtml(body);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -367,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
|
||||
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
|
||||
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
|
||||
`;
|
||||
return buildEmailHtml(subject, block, lang);
|
||||
return buildEmailHtml(subject, block, lang, undefined, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ADDON_IDS } from '../addons';
|
||||
import { User } from '../types';
|
||||
import { writeAudit, logWarn } from './auditLog';
|
||||
import { revokeUserSessionsForClient } from '../mcp/sessionManager';
|
||||
import { getAppUrl } from './oidcService';
|
||||
import { getMcpSafeUrl } from './notifications';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -60,6 +60,7 @@ interface OAuthClientRow {
|
||||
created_at: string;
|
||||
is_public: number; // 0 | 1 (SQLite boolean)
|
||||
created_via: string; // 'settings_ui' | 'browser-registration'
|
||||
allows_client_credentials: number; // 0 | 1
|
||||
}
|
||||
|
||||
interface OAuthTokenRow {
|
||||
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
|
||||
|
||||
export function listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as OAuthClientRow[];
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
is_public: Boolean(r.is_public),
|
||||
allows_client_credentials: Boolean(r.allows_client_credentials),
|
||||
redirect_uris: JSON.parse(r.redirect_uris),
|
||||
allowed_scopes: JSON.parse(r.allowed_scopes),
|
||||
}));
|
||||
@@ -132,11 +134,12 @@ export function createOAuthClient(
|
||||
redirectUris: string[],
|
||||
allowedScopes: string[],
|
||||
ip?: string | null,
|
||||
options?: { isPublic?: boolean; createdVia?: string },
|
||||
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
|
||||
): { error?: string; status?: number; client?: Record<string, unknown> } {
|
||||
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
||||
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
|
||||
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
const isMachineClient = Boolean(options?.allowsClientCredentials);
|
||||
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
|
||||
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
|
||||
|
||||
for (const uri of redirectUris) {
|
||||
@@ -164,7 +167,8 @@ export function createOAuthClient(
|
||||
if (count >= 500) return { error: 'server_error', status: 503 };
|
||||
}
|
||||
|
||||
const isPublic = options?.isPublic ?? false;
|
||||
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
|
||||
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
|
||||
const createdVia = options?.createdVia ?? 'settings_ui';
|
||||
const id = randomUUID();
|
||||
const clientId = randomUUID();
|
||||
@@ -173,14 +177,14 @@ export function createOAuthClient(
|
||||
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
|
||||
|
||||
db.prepare(
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
|
||||
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?'
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
|
||||
).get(id) as OAuthClientRow;
|
||||
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
|
||||
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
|
||||
|
||||
return {
|
||||
client: {
|
||||
@@ -192,6 +196,7 @@ export function createOAuthClient(
|
||||
allowed_scopes: JSON.parse(row.allowed_scopes),
|
||||
created_at: row.created_at,
|
||||
is_public: Boolean(row.is_public),
|
||||
allows_client_credentials: Boolean(row.allows_client_credentials),
|
||||
created_via: row.created_via,
|
||||
// client_secret only present for confidential clients — shown once, not stored in plain text
|
||||
...(rawSecret ? { client_secret: rawSecret } : {}),
|
||||
@@ -330,6 +335,43 @@ export function issueTokens(
|
||||
};
|
||||
}
|
||||
|
||||
// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
|
||||
// Used exclusively for the client_credentials grant. A random opaque hash is
|
||||
// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
|
||||
// can never be presented as a valid refresh token (same precedent as public
|
||||
// client secret hashes stored in client_secret_hash).
|
||||
export function issueClientCredentialsToken(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
audience: string,
|
||||
): {
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
const rawAccess = generateAccessToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const placeholderHash = randomBytes(32).toString('hex');
|
||||
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO oauth_tokens
|
||||
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
|
||||
|
||||
return {
|
||||
access_token: rawAccess,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
scope: scopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token verification (used by MCP handler on every request)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -587,7 +629,7 @@ export function validateAuthorizeRequest(
|
||||
// bind the token to the MCP endpoint by default — previously this
|
||||
// left `audience = null`, and the audience-bind check on MCP requests
|
||||
// then treated a null audience as "valid for any resource".
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const mcpResource = `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource
|
||||
? params.resource.replace(/\/+$/, '')
|
||||
: mcpResource;
|
||||
|
||||
@@ -194,14 +194,6 @@ export function generateToken(user: { id: number }): string {
|
||||
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: '24h', algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
export function getAppUrl(): string | null {
|
||||
return (
|
||||
process.env.APP_URL ||
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'app_url'").get() as { value: string } | undefined)?.value ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token exchange with OIDC provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user