mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an independent check against the code is fixed here, with a test for each. Data and credentials - A document sync connection edited to a new address no longer carries the stored token along: a blank form under another origin is refused, the same rule the probe route already had. Dawarich and AirTrail testConnection send the stored key only to the host it was stored for. - The key rotation script now covers document_connections.secrets and trip_document_links.webhook_secret, which it had skipped. - Turning a booked night into a pause in the road trip popup asks first and says that the booking and its expense go with it. Deleting a place says the same when a night is booked there, on the desktop and on the phone. - A moved version marker over unchanged bytes no longer replaces the TREK file and its attachments; a real revision carries the attachments over. Planner - A drop in the day plan names the row it lands ahead of in the full day, so a hidden hotel stop no longer shifts it one slot early; the store sorts before it splices. Via re-anchoring, the booked-night guard and the sidebar's day membership read the stored day, the index space the server uses. - A hotel stop placed mid-day re-pins the road trip vias of that day, and the booking session receives the new stop order too. The Public API and the iCal feed list a booked hotel once. A plugin can no longer write a stay against another trip's day. Road trip and map - The route overview paces its requests to the public routers and retries a rate limit, and it says which legs it could not route instead of quietly reporting a shorter trip. A click on a via handle no longer drops a second via; handles hide when zooming out. Read-only members cannot edit the driving limits. The phone shows a way back when no day is picked and prints the night drive departure in the chosen clock format. Integrations and search - Google transit keeps conventional trains when the train chip is on. TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and accepts the whole boolean family. A caller-supplied description no longer lands in the instance-wide enrichment cache. Nominatim searches carry a timeout. An OSM place id is checked for shape before it reaches Overpass. - Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off knows what the Atlas already shows, and a host change with a new key clears the old instance's state. - Document sync: a webhook address pasted by hand works on the token alone, deleting a connection unregisters its subscriptions, Nextcloud under a sub-path connects, Synology answers a name collision before uploading and verifies a move into the bin, Paperless titles are capped at 128 characters, a DNS failure reads as unreachable rather than as a policy verdict, videos from the store are accepted like uploads, and remote_missing has a way out. Conflict buttons only for the owner, a refused choice is reported, a refused credential gets a Reconnect button, disconnecting asks first on the desktop as it does on the phone, and the folder picker no longer spins forever. Release - The server holds the release notice back from a bundle that cannot draw it, so the previous shell served from the service worker cache after an update no longer shows bare keys and no longer lets a click use the notice up. - Import into an existing list gets the same body limit as a new list. Chat images say when the limit is reached and the new controls are translated. The transit backend switch reports a failed save. The Helm chart carries the Amap credentials in its Secret; the shipped templates document the Places API and Amap variables and the Overpass default. The wiki follows.
This commit is contained in:
@@ -85,3 +85,15 @@ data:
|
||||
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
|
||||
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
|
||||
{{- end }}
|
||||
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
|
||||
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
|
||||
otherwise drop the key and leave the index on. */}}
|
||||
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
|
||||
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -75,6 +75,18 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_SECRET
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
|
||||
@@ -20,6 +20,12 @@ data:
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -50,4 +56,10 @@ stringData:
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
+27
-2
@@ -90,8 +90,24 @@ env:
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||
# OVERPASS_TIMEOUT_MS: "12000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
|
||||
# OVERPASS_TIMEOUT_MS: "25000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
# TREK_PLACES_URL: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -117,6 +133,15 @@ secretEnv:
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
|
||||
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
|
||||
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
|
||||
# key from the admin panel, or none.
|
||||
AMAP_API_KEY: ""
|
||||
# The private secret (数字签名) of that key. Required exactly when the key was created
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-APISURF-001 to FE-APISURF-056
|
||||
// FE-APISURF-001 to FE-APISURF-057
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
||||
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
|
||||
docsyncApi,
|
||||
docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
|
||||
} from './client'
|
||||
|
||||
interface Recorded { method: string; url: string; body: unknown }
|
||||
@@ -902,4 +902,47 @@ describe('client > multipart uploads', () => {
|
||||
expect(call[2]).toMatchObject({ timeout: 40000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
|
||||
const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
|
||||
|
||||
// The server allows each request to a store 15 s, and a run waits for the
|
||||
// whole listing and every transfer; cut off at 8 s the browser reported a
|
||||
// failure while the server carried on and finished.
|
||||
await docsyncApi.testConnection(1, { providerId: 'paperless' })
|
||||
await docsyncApi.createScope(1, 5, 'Norway')
|
||||
await docsyncApi.createLink(1, { connectionId: 5 })
|
||||
await docsyncApi.listScopes(1, 5)
|
||||
await docsyncApi.deleteLink(1, 9)
|
||||
|
||||
expect(post.mock.calls.map(c => c[0])).toEqual([
|
||||
'/trips/1/docsync/connections/test',
|
||||
'/trips/1/docsync/connections/5/scopes',
|
||||
'/trips/1/docsync/links',
|
||||
])
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
}
|
||||
expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes')
|
||||
expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9')
|
||||
expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000)
|
||||
|
||||
// A run, and a conflict choice that runs the binding afterwards.
|
||||
post.mockClear()
|
||||
await docsyncApi.syncNow(1, 9)
|
||||
await docsyncApi.resolve(1, 4, 'trek')
|
||||
|
||||
expect(post.mock.calls.map(c => c[0])).toEqual([
|
||||
'/trips/1/docsync/links/9/sync',
|
||||
'/trips/1/docsync/items/4/resolve',
|
||||
])
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS })
|
||||
}
|
||||
expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1361,6 +1361,34 @@ export const filesApi = {
|
||||
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the browser waits for a document-sync call that makes the server go
|
||||
* and ask the store.
|
||||
*
|
||||
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
|
||||
* these, for the reason `dawarich.ts` gives: the server allows each request to
|
||||
* a store 15 s by itself (20 s for Papra), a connection test may probe more
|
||||
* than once, and binding or unbinding first registers or removes a webhook at
|
||||
* the store. Cut off at 8 s, the browser reported a failure while the server
|
||||
* carried on and finished, so a listing that took twelve seconds read as a
|
||||
* broken store.
|
||||
*/
|
||||
export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000
|
||||
|
||||
/**
|
||||
* A run is longer again. The server answers only once the whole of it is
|
||||
* through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s
|
||||
* each, and Paperless-ngx holds every push until its consume task has run.
|
||||
* Resolving a conflict runs the binding afterwards, so it waits just as long.
|
||||
* Ten minutes covers a full transfer budget of ordinary documents; a run that
|
||||
* outlasts it still finishes on the server, and the `docsync:changed` ping
|
||||
* refreshes the dialog when it does.
|
||||
*/
|
||||
export const DOCSYNC_RUN_TIMEOUT_MS = 600_000
|
||||
|
||||
const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }
|
||||
const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS }
|
||||
|
||||
/**
|
||||
* Document sync: one provider connection per trip, shared by every member.
|
||||
*
|
||||
@@ -1373,22 +1401,22 @@ export const docsyncApi = {
|
||||
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
|
||||
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
|
||||
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
|
||||
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data),
|
||||
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data),
|
||||
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
|
||||
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
|
||||
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data),
|
||||
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data),
|
||||
createScope: (tripId: number | string, connectionId: number, name: string) =>
|
||||
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data),
|
||||
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data),
|
||||
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data),
|
||||
syncNow: (tripId: number | string, linkId: number, full = false) =>
|
||||
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data),
|
||||
items: (tripId: number | string, state?: string) =>
|
||||
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
|
||||
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
|
||||
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-036
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
@@ -543,6 +543,22 @@ describe('AddonManager', () => {
|
||||
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
|
||||
// The row's tooltip is its description. The links tab's empty state read as a
|
||||
// status here, and a status that never changes reads as a broken feature.
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
expect(links).toHaveAttribute('title');
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
|
||||
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
|
||||
render(<AddonManager />);
|
||||
|
||||
@@ -99,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
@@ -708,4 +708,31 @@ describe('CollabChat', () => {
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
// The strip shows the four pictures that will go out; the two the cap turned away
|
||||
// vanish without a word otherwise, and the sender believes all six were sent.
|
||||
it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
let minted = 0;
|
||||
const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`);
|
||||
const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
try {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText('Start the conversation');
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' }));
|
||||
|
||||
fireEvent.change(input, { target: { files } });
|
||||
|
||||
await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
const [message, type] = addToast.mock.calls[0];
|
||||
expect(type).toBe('error');
|
||||
expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed');
|
||||
} finally {
|
||||
delete window.__addToast;
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,8 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label={t('places.removeImage')} style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>{t('collab.chat.uploading', { percent: uploadProgress })}</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -63,7 +63,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label={t('collab.chat.attachImages')} style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const alt = attachment.original_name || t('collab.chat.attachedImage')
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
<img src={attachment.url} alt={alt} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label={t('collab.chat.closeImage')} onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={alt} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
|
||||
* FE-CHATIMG-001..009: the pictures pinned to a chat message before it is sent.
|
||||
*
|
||||
* The reason this file exists: the hook used to create object URLs and enqueue
|
||||
* the preview state from inside a `setFiles` updater. React invokes an updater
|
||||
@@ -11,7 +11,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { StrictMode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
|
||||
import { useChatImages, MAX_CHAT_IMAGES, type ChatImageDrop } from './useChatImages'
|
||||
|
||||
let nextUrl = 0
|
||||
const revoked: string[] = []
|
||||
@@ -92,4 +92,44 @@ describe('useChatImages', () => {
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
|
||||
})
|
||||
|
||||
// The strip shows what will be sent, so a picture the cap turned away is as gone as
|
||||
// one of the wrong kind. The sender hears about both, and hears which it was.
|
||||
it('FE-CHATIMG-007: a pick past the cap is reported as an overflow and is not a clean add', () => {
|
||||
const { result } = strict()
|
||||
const drops: ChatImageDrop[] = []
|
||||
let ok = true
|
||||
|
||||
act(() => {
|
||||
ok = result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`)), d => drops.push(d))
|
||||
})
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(drops).toEqual([{ rejected: 0, overflow: 2 }])
|
||||
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-008: a wrong kind and an overflow in one pick are told apart', () => {
|
||||
const { result } = strict()
|
||||
act(() => { result.current.add([img('a.png'), img('b.png'), img('c.png')]) })
|
||||
const drops: ChatImageDrop[] = []
|
||||
|
||||
act(() => {
|
||||
result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' }), img('d.png'), img('e.png')], d => drops.push(d))
|
||||
})
|
||||
|
||||
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png', 'd.png'])
|
||||
expect(drops).toEqual([{ rejected: 1, overflow: 1 }])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-009: a pick that fits is a clean add and reports nothing', () => {
|
||||
const { result } = strict()
|
||||
const onDrop = vi.fn()
|
||||
let ok = false
|
||||
|
||||
act(() => { ok = result.current.add([img('a.png')], onDrop) })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(onDrop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,11 +28,24 @@ interface PickedImage {
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What an add() left out: files of the wrong type or over the size limit, and
|
||||
* valid files past the cap of MAX_CHAT_IMAGES. Either can be zero, both cannot.
|
||||
*/
|
||||
export interface ChatImageDrop {
|
||||
rejected: number
|
||||
overflow: number
|
||||
}
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
/**
|
||||
* Returns false when something was dropped, so the caller can say why. The
|
||||
* optional callback tells it what was dropped, because a picture past the cap
|
||||
* is a different message from a picture of the wrong kind.
|
||||
*/
|
||||
add: (incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
@@ -52,7 +65,7 @@ export function useChatImages(): ChatImages {
|
||||
itemsRef.current = []
|
||||
}, [])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const add = useCallback((incoming: FileList | File[], onDrop?: (drop: ChatImageDrop) => void) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
const room = MAX_CHAT_IMAGES - itemsRef.current.length
|
||||
@@ -61,7 +74,11 @@ export function useChatImages(): ChatImages {
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
|
||||
}
|
||||
return valid.length === all.length
|
||||
// A picture the cap turned away is as gone as one of the wrong kind: the strip
|
||||
// shows what will be sent, and the sender deserves to hear why it is short.
|
||||
const drop = { rejected: all.length - valid.length, overflow: valid.length - added.length }
|
||||
if (drop.rejected || drop.overflow) onDrop?.(drop)
|
||||
return drop.rejected === 0 && drop.overflow === 0
|
||||
}, [commit])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -122,7 +122,10 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
images.add(incoming, ({ rejected, overflow }) => {
|
||||
if (rejected) toast.error(t('collab.chat.imageRejected'))
|
||||
if (overflow) toast.error(t('collab.chat.imageLimit', { max: MAX_CHAT_IMAGES }))
|
||||
})
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
|
||||
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-026
|
||||
/**
|
||||
* The review step between a detector's guess and a row in somebody's trip.
|
||||
*
|
||||
@@ -11,7 +11,8 @@
|
||||
* pinning, and it is the one a refactor breaks first.
|
||||
*
|
||||
* The rest is the shape of the three targets. `place` needs a trip and offers a
|
||||
* day, `journal` needs a journey and asks for a story, `bucket_list` needs a
|
||||
* day (and no date: the day IS where it lands, and the server reads no date for
|
||||
* a place), `journal` needs a journey, a date and a story, `bucket_list` needs a
|
||||
* matched wish and hides the clock entirely because ticking a wish off uses the
|
||||
* recorded timestamp and nothing else. Each of the three can be in a state
|
||||
* where there is nothing to write to (no trips, no journeys, no matched wish),
|
||||
@@ -173,20 +174,20 @@ describe('DawarichAcceptDialog', () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
|
||||
typeDate('2026-09-12')
|
||||
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
|
||||
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
// The day is the only calendar fact a place carries: there is no date in
|
||||
// this body because there is no date field on this rail.
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'place',
|
||||
tripId: 31,
|
||||
dayId: 401,
|
||||
name: 'Café Reichard am Dom',
|
||||
notes: 'Two hours and a lot of cake.',
|
||||
date: '2026-09-12',
|
||||
time: '09:00',
|
||||
endTime: '11:45',
|
||||
})
|
||||
@@ -381,10 +382,11 @@ describe('DawarichAcceptDialog', () => {
|
||||
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
|
||||
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
|
||||
// answers with an empty string rather than a guess, and an empty string must
|
||||
// not reach the body as `time: ""`.
|
||||
// not reach the body as `time: ""` or `date: ""`. On the journal rail,
|
||||
// because that is the one rail that shows the date beside the clock.
|
||||
const { onConfirm } = open({
|
||||
trips: TRIPS,
|
||||
daysForTrip,
|
||||
target: 'journal',
|
||||
journals: JOURNALS,
|
||||
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
|
||||
})
|
||||
|
||||
@@ -392,8 +394,8 @@ describe('DawarichAcceptDialog', () => {
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'journal', journalId: 71 })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
|
||||
@@ -524,4 +526,43 @@ describe('DawarichAcceptDialog', () => {
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-025: the place rail offers no date, because the day picker is where a place lands', () => {
|
||||
open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
// A place has no date of its own: the server reads a date for a journal
|
||||
// entry and for nothing else, so a date field on this rail was one whose
|
||||
// change moved the place nowhere while the toast still said it landed.
|
||||
// The clock stays, the server does read that.
|
||||
expect(screen.queryByText('Date')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Enter date manually' })).toBeNull()
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-026: a corrected date travels with a journal entry, and never with a place', () => {
|
||||
// Both rails on one stay, so the dialog holds the corrected date in state
|
||||
// across the switch: the same shape as switching rails with a note typed.
|
||||
const { onConfirm, update } = open({ target: 'journal', journals: JOURNALS, trips: TRIPS, daysForTrip })
|
||||
|
||||
expect(screen.getByText('Date')).toBeInTheDocument()
|
||||
typeDate('2026-09-12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith({
|
||||
target: 'journal',
|
||||
journalId: 71,
|
||||
date: '2026-09-12',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
|
||||
// Same stay, now as a place. The corrected date is still in state and would
|
||||
// be silently dropped by the server; it must not be in the body at all,
|
||||
// so what the dialog claims to send is what is actually written.
|
||||
update({ target: 'place' })
|
||||
expect(screen.queryByText('Date')).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,7 +114,10 @@ export default function DawarichAcceptDialog({
|
||||
}
|
||||
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
|
||||
if (notes.trim()) body.notes = notes.trim()
|
||||
if (date && date !== suggestion.localDate) body.date = date
|
||||
// Only an entry carries a date. A place lands on the day chosen below and
|
||||
// the server reads nothing else for it, so a date corrected on the journal
|
||||
// rail must not ride along when the same stay is then added as a place.
|
||||
if (target === 'journal' && date && date !== suggestion.localDate) body.date = date
|
||||
if (time) body.time = time
|
||||
if (endTime) body.endTime = endTime
|
||||
onConfirm(body)
|
||||
@@ -199,15 +202,27 @@ export default function DawarichAcceptDialog({
|
||||
itself from the OS locale and a native `type="time"` ignores the 12h/24h
|
||||
setting outright (#2067) — and neither follows the colour scheme. The
|
||||
date column is the wide one because a localized date is long and a
|
||||
clock never is. */}
|
||||
clock never is.
|
||||
|
||||
The date is the journal rail's alone. A place has no date of its own:
|
||||
the day picker below is where it lands, and a date field beside that
|
||||
picker was a second answer to the same question, one the server never
|
||||
read. The two clocks then share the row the way they do on the place
|
||||
form. */}
|
||||
{target !== 'bucket_list' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-2 ${
|
||||
target === 'journal' ? 'sm:grid-cols-[minmax(0,1fr)_124px_124px]' : 'sm:grid-cols-2'
|
||||
}`}
|
||||
>
|
||||
{target === 'journal' && (
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.from')}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-026
|
||||
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-030
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import en from '@trek/shared/i18n/en'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
|
||||
import DocSyncBinding from './DocSyncBinding'
|
||||
import type { DocSyncLink, useDocSync } from './useDocSync'
|
||||
@@ -64,6 +65,7 @@ function renderCard(
|
||||
sync?: Partial<Sync>
|
||||
canManage?: boolean
|
||||
providerName?: string
|
||||
onReconnect?: () => void
|
||||
} = {},
|
||||
) {
|
||||
const utils = render(
|
||||
@@ -72,6 +74,7 @@ function renderCard(
|
||||
providerName={opts.providerName ?? 'Paperless'}
|
||||
sync={makeSync(opts.sync)}
|
||||
canManage={opts.canManage ?? true}
|
||||
onReconnect={opts.onReconnect}
|
||||
/>,
|
||||
)
|
||||
const header = utils.container.querySelector('header') as HTMLElement
|
||||
@@ -82,6 +85,15 @@ const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'S
|
||||
|
||||
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
|
||||
|
||||
const UNLINK_QUESTION = 'Documents stay in TREK and at the store. Only the pairing between them goes.'
|
||||
|
||||
/** The confirmation the disconnect button opens; it renders in a portal. */
|
||||
const unlinkDialog = () => within(screen.getByText(UNLINK_QUESTION).closest('.trek-modal-enter') as HTMLElement)
|
||||
|
||||
/** Named through the same fallback chain `t()` uses, so the label can land in the locales later. */
|
||||
const reconnectButton = () =>
|
||||
screen.queryByRole('button', { name: (en as unknown as Record<string, string>)['docsync.binding.reconnect'] ?? 'docsync.binding.reconnect' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -112,12 +124,75 @@ describe('DocSyncBinding: running a sync', () => {
|
||||
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-004: the disconnect button unlinks this binding', () => {
|
||||
it('FE-DOCSYNC-BIND-004: the disconnect button asks first and unlinks once that is confirmed', () => {
|
||||
// As the phone does. The button sits beside "Sync now", and a binding
|
||||
// cannot be put back: rebinding the same folder starts from nothing.
|
||||
renderCard()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
|
||||
|
||||
expect(removeLink).not.toHaveBeenCalled()
|
||||
expect(screen.getByText(UNLINK_QUESTION)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Disconnect' }))
|
||||
|
||||
expect(removeLink).toHaveBeenCalledWith(4)
|
||||
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-027: cancelling the question leaves the binding as it was', () => {
|
||||
renderCard()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
|
||||
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(removeLink).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncBinding: a refused credential', () => {
|
||||
it('FE-DOCSYNC-BIND-028: offers to sign in again beside the notice, and hands the click to the panel', () => {
|
||||
// The sidebar opens the credential form only for a store with no
|
||||
// connection yet, so a rotated token had no way back in from the app.
|
||||
const onReconnect = vi.fn()
|
||||
renderCard({ link: { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' }, onReconnect })
|
||||
|
||||
expect(screen.getByText('Sign in again · The credentials were refused.')).toBeInTheDocument()
|
||||
fireEvent.click(reconnectButton() as HTMLElement)
|
||||
|
||||
expect(onReconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-029: a refusal recorded as a failed run offers it too', () => {
|
||||
const onReconnect = vi.fn()
|
||||
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' }, onReconnect })
|
||||
|
||||
expect(reconnectButton()).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-BIND-030: is not offered to a member, for another failure, or while the provider is switched off', () => {
|
||||
const onReconnect = vi.fn()
|
||||
const refused = { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' } as const
|
||||
|
||||
const member = renderCard({ link: refused, onReconnect, canManage: false })
|
||||
expect(reconnectButton()).not.toBeInTheDocument()
|
||||
member.unmount()
|
||||
|
||||
const other = renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unreachable' }, onReconnect })
|
||||
expect(reconnectButton()).not.toBeInTheDocument()
|
||||
other.unmount()
|
||||
|
||||
// An admin has switched the store off: the paused notice stands in front,
|
||||
// and a new credential would change nothing until it is back on.
|
||||
const paused = renderCard({ link: { ...refused, providerOff: true }, onReconnect })
|
||||
expect(reconnectButton()).not.toBeInTheDocument()
|
||||
paused.unmount()
|
||||
|
||||
// The panel could not find the provider's form: nothing to open.
|
||||
renderCard({ link: refused })
|
||||
expect(reconnectButton()).not.toBeInTheDocument()
|
||||
expect(onReconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import Tooltip from '../../shared/Tooltip'
|
||||
@@ -9,7 +10,7 @@ import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
|
||||
import { Badge, CONFLICT_POLICIES, conflictPolicyKey, LastRun, StateBadge } from './DocSyncBits'
|
||||
import { bindingNotice, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
import { bindingNotice, needsReauth, type DocSyncLink, type useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* One binding: where this trip's documents live, which way they move, and what
|
||||
@@ -25,18 +26,30 @@ export default function DocSyncBinding({
|
||||
providerName,
|
||||
sync,
|
||||
canManage,
|
||||
onReconnect,
|
||||
}: {
|
||||
link: DocSyncLink
|
||||
providerName: string
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
canManage: boolean
|
||||
/**
|
||||
* Opens the credential form for this binding's store. Only the panel can,
|
||||
* because the form needs the provider's field list, which the card does
|
||||
* not carry; left out, a refused credential is reported but not curable.
|
||||
*/
|
||||
onReconnect?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [confirmUnlink, setConfirmUnlink] = useState(false)
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
|
||||
const busy = sync.busy === `sync-${link.id}`
|
||||
const notice = bindingNotice(link, t)
|
||||
// Not while the provider is switched off: the paused notice stands in front
|
||||
// of the refusal then, and a new credential would change nothing until an
|
||||
// admin turns the provider back on.
|
||||
const reconnect = canManage && onReconnect && !link.providerOff && needsReauth(link) ? onReconnect : null
|
||||
|
||||
const copyWebhook = async () => {
|
||||
if (!link.webhookUrl) return
|
||||
@@ -97,11 +110,15 @@ export default function DocSyncBinding({
|
||||
text still changes to "Syncing". */}
|
||||
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
|
||||
</button>
|
||||
{/* Asked first, as the phone does: the button sits a hand's width
|
||||
from "Sync now", and a binding cannot be put back. Rebinding the
|
||||
same folder starts from nothing and copies both sides over
|
||||
again. */}
|
||||
{canManage && (
|
||||
<Tooltip label={t('docsync.unlink')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sync.removeLink(link.id)}
|
||||
onClick={() => setConfirmUnlink(true)}
|
||||
className="grid h-9 w-9 place-items-center rounded-lg border border-edge bg-surface text-content-muted transition-colors hover:bg-danger-soft hover:text-danger"
|
||||
aria-label={t('docsync.unlink')}
|
||||
>
|
||||
@@ -115,10 +132,31 @@ export default function DocSyncBinding({
|
||||
{notice && (
|
||||
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
|
||||
<AlertTriangle size={13} className="mt-px shrink-0" />
|
||||
<span>{notice}</span>
|
||||
<span className="min-w-0 flex-1">{notice}</span>
|
||||
{/* The one state a person can act on from here. Without this the
|
||||
only way past a rotated token was the API: the sidebar opens the
|
||||
form for a store that has no connection yet, and this one has. */}
|
||||
{reconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
className="shrink-0 rounded-lg border border-edge bg-surface px-2.5 py-1 text-caption font-medium text-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{t('docsync.binding.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmUnlink}
|
||||
onClose={() => setConfirmUnlink(false)}
|
||||
onConfirm={() => void sync.removeLink(link.id)}
|
||||
title={t('docsync.unlink')}
|
||||
message={t('docsync.confirmUnlink')}
|
||||
confirmLabel={t('docsync.unlink')}
|
||||
/>
|
||||
|
||||
<div className="px-4 pb-3">
|
||||
<DocSyncFlow
|
||||
direction={link.direction}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-018
|
||||
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-022
|
||||
|
||||
/**
|
||||
* The document-sync dialog shell (#2391).
|
||||
@@ -25,6 +25,9 @@ const status = vi.fn()
|
||||
const deleteLink = vi.fn()
|
||||
const updateLink = vi.fn()
|
||||
const syncNow = vi.fn()
|
||||
const items = vi.fn()
|
||||
const resolve = vi.fn()
|
||||
const saveConnection = vi.fn()
|
||||
|
||||
vi.mock('../../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../api/client')>()
|
||||
@@ -39,6 +42,9 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
||||
deleteLink: (tripId: number | string, linkId: number) => deleteLink(tripId, linkId),
|
||||
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLink(tripId, linkId, patch),
|
||||
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNow(tripId, linkId, full),
|
||||
items: (tripId: number | string, state?: string) => items(tripId, state),
|
||||
resolve: (tripId: number | string, itemId: number, keep: string) => resolve(tripId, itemId, keep),
|
||||
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -76,15 +82,17 @@ const link = (id: number, providerId: string, overrides: Partial<DocSyncLink> =
|
||||
/** What the server answers, as one object the tests reshape per case. */
|
||||
function serverHas({
|
||||
providers: p = [],
|
||||
connections = [],
|
||||
links = [],
|
||||
items = {},
|
||||
}: {
|
||||
providers?: DocSyncProvider[]
|
||||
connections?: Array<Record<string, unknown>>
|
||||
links?: DocSyncLink[]
|
||||
items?: Record<string, number>
|
||||
}): void {
|
||||
providers.mockResolvedValue(p)
|
||||
listConnections.mockResolvedValue([])
|
||||
listConnections.mockResolvedValue(connections)
|
||||
listLinks.mockResolvedValue(links)
|
||||
status.mockResolvedValue({
|
||||
items,
|
||||
@@ -92,6 +100,18 @@ function serverHas({
|
||||
})
|
||||
}
|
||||
|
||||
/** The stored connection a binding runs under. */
|
||||
const connection = (id: number, providerId: string) => ({
|
||||
id,
|
||||
providerId,
|
||||
baseUrl: 'https://docs.example.org',
|
||||
settings: {},
|
||||
secrets: { token: '***' },
|
||||
allowInsecureTls: false,
|
||||
lastProbeState: 'ok',
|
||||
lastProbeError: null,
|
||||
})
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(r => { resolve = r })
|
||||
@@ -110,12 +130,25 @@ const sidebar = () => screen.getByRole('navigation')
|
||||
const group = (heading: string) =>
|
||||
within(screen.getByRole('heading', { name: heading }).parentElement as HTMLElement)
|
||||
|
||||
/** The "needs a look" section under the binding card. */
|
||||
const issues = () =>
|
||||
within(screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement)
|
||||
|
||||
/** Disconnect asks first; this answers yes. The question renders in a portal. */
|
||||
const confirmUnlink = () => {
|
||||
const dialog = screen.getByText(t('docsync.confirmUnlink')).closest('.trek-modal-enter') as HTMLElement
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: t('docsync.unlink') }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
serverHas({})
|
||||
deleteLink.mockResolvedValue({})
|
||||
updateLink.mockResolvedValue({})
|
||||
syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
|
||||
items.mockResolvedValue([])
|
||||
resolve.mockResolvedValue({ success: true })
|
||||
saveConnection.mockResolvedValue({ id: 1 })
|
||||
})
|
||||
|
||||
describe('DocSyncPanel: first load', () => {
|
||||
@@ -316,6 +349,7 @@ describe('DocSyncPanel: removing a binding', () => {
|
||||
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
|
||||
confirmUnlink()
|
||||
|
||||
await waitFor(() => expect(deleteLink).toHaveBeenCalledWith(7, 2))
|
||||
await waitFor(() => expect(within(binding()).getByText('Folder 1')).toBeInTheDocument())
|
||||
@@ -334,6 +368,7 @@ describe('DocSyncPanel: removing a binding', () => {
|
||||
await screen.findByRole('article')
|
||||
|
||||
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
|
||||
confirmUnlink()
|
||||
|
||||
expect(await screen.findByText(t('docsync.empty.title'))).toBeInTheDocument()
|
||||
expect(screen.getByText(t('docsync.empty.hintOwner'))).toBeInTheDocument()
|
||||
@@ -388,6 +423,93 @@ describe('DocSyncPanel: what needs a look', () => {
|
||||
expect(within(rows[1]).getByText('1')).toBeInTheDocument()
|
||||
expect(panel.queryByText(t('docsync.state.pending'))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-PANEL-019: a member is shown the conflict count but offered no choice', async () => {
|
||||
// The server refuses a member's choice with 403, so the three buttons
|
||||
// would only ever fail for them. The count is what they are owed.
|
||||
serverHas({
|
||||
providers: [provider('paperless', 'Paperless-ngx')],
|
||||
links: [link(1, 'paperless')],
|
||||
items: { conflict: 2 },
|
||||
})
|
||||
|
||||
render(<DocSyncPanel tripId={7} canManage={false} onClose={onClose} />)
|
||||
await screen.findByRole('article')
|
||||
|
||||
const row = issues().getAllByRole('listitem')[0]
|
||||
expect(within(row).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
|
||||
expect(within(row).getByText('2')).toBeInTheDocument()
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(items).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-PANEL-020: a choice the server refuses is said in the column, and the row stays', async () => {
|
||||
serverHas({
|
||||
providers: [provider('paperless', 'Paperless-ngx')],
|
||||
links: [link(1, 'paperless')],
|
||||
items: { conflict: 1 },
|
||||
})
|
||||
items.mockResolvedValue([{ id: 40, file_name: 'boarding-pass.pdf', remote_name: 'boarding-pass.pdf' }])
|
||||
// Settled by the owner in another tab a moment ago.
|
||||
resolve.mockRejectedValue({ response: { status: 400, data: { error: 'conflict' } } })
|
||||
|
||||
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
|
||||
await screen.findByRole('article')
|
||||
|
||||
fireEvent.click(issues().getByRole('button', { name: /Resolve/ }))
|
||||
fireEvent.click(await issues().findByRole('button', { name: t('docsync.conflict.keepTrek') }))
|
||||
|
||||
await waitFor(() => expect(resolve).toHaveBeenCalledWith(7, 40, 'trek'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.conflict'))
|
||||
// The list is read back rather than trimmed by hand, so the row is still
|
||||
// there, ready for another try.
|
||||
await waitFor(() => expect(issues().getByRole('button', { name: t('docsync.conflict.keepTrek') })).toBeEnabled())
|
||||
expect(issues().getByText('boarding-pass.pdf')).toBeInTheDocument()
|
||||
expect(items).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncPanel: what a click on the card reports', () => {
|
||||
it('FE-DOCSYNC-PANEL-021: a refused run is said under the card rather than swallowed', async () => {
|
||||
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
|
||||
syncNow.mockRejectedValue({ response: { status: 409, data: { error: 'provider_disabled' } } })
|
||||
|
||||
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
|
||||
await screen.findByRole('article')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.syncNow') }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(t('docsync.error.provider_disabled'))
|
||||
expect(screen.getByRole('article')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-PANEL-022: a refused credential opens the form for the bound store, and saving runs the binding', async () => {
|
||||
// The sidebar only ever opened the form for a store with no connection
|
||||
// yet, and a bound store is not listed there, so a rotated token could
|
||||
// only be entered through the API.
|
||||
serverHas({
|
||||
providers: [provider('paperless', 'Paperless-ngx')],
|
||||
connections: [connection(5, 'paperless')],
|
||||
links: [link(1, 'paperless', { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })],
|
||||
})
|
||||
|
||||
render(<DocSyncPanel tripId={7} canManage onClose={onClose} />)
|
||||
await screen.findByRole('article')
|
||||
|
||||
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.binding.reconnect') }))
|
||||
|
||||
// The credential form, for this store, not the folder picker.
|
||||
expect(screen.getByRole('button', { name: t('docsync.connect.submit') })).toBeInTheDocument()
|
||||
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: t('docsync.connect.submit') }))
|
||||
|
||||
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(7, expect.objectContaining({ providerId: 'paperless' })))
|
||||
await waitFor(() => expect(syncNow).toHaveBeenCalledWith(7, 1, false))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: t('docsync.connect.submit') })).not.toBeInTheDocument())
|
||||
expect(screen.queryByText(t('docsync.scope.pickTitle'))).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocSyncPanel: the dialog itself', () => {
|
||||
|
||||
@@ -41,10 +41,17 @@ export default function DocSyncPanel({
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [connecting, setConnecting] = useState<DocSyncProvider | null>(null)
|
||||
const [scopeFor, setScopeFor] = useState<string | null>(null)
|
||||
// The credential form again, for a store this trip is already bound to. Kept
|
||||
// apart from `connecting`, whose next step is the folder picker: here the
|
||||
// folder is known and the next step is the run that the refusal stopped.
|
||||
const [reconnecting, setReconnecting] = useState<DocSyncProvider | null>(null)
|
||||
|
||||
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
|
||||
const available = sync.providers.filter(p => !bound.has(p.id))
|
||||
const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null
|
||||
// Undefined once an admin has switched the store off: the form needs the
|
||||
// provider's field list, and the providers route no longer carries it.
|
||||
const activeProvider = active ? sync.providers.find(p => p.id === active.providerId) : undefined
|
||||
|
||||
// Follow the list: a freshly bound store should be the one on screen, and a
|
||||
// removed one must not leave the detail column pointing at nothing.
|
||||
@@ -98,8 +105,20 @@ export default function DocSyncPanel({
|
||||
providerName={storeName(active, sync.providers)}
|
||||
sync={sync}
|
||||
canManage={canManage}
|
||||
onReconnect={activeProvider ? () => setReconnecting(activeProvider) : undefined}
|
||||
/>
|
||||
{attention > 0 && <Attention counts={sync.itemCounts} tripId={tripId} sync={sync} />}
|
||||
{/* Every action in this column reports here: a refused run, a
|
||||
switch that flipped back, a conflict the server would not
|
||||
settle. The two dialogs say their own, but nothing said
|
||||
what happened to a click on the card itself. */}
|
||||
{sync.error && (
|
||||
<p role="alert" className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
|
||||
{t(`docsync.error.${sync.error}`)}
|
||||
</p>
|
||||
)}
|
||||
{attention > 0 && (
|
||||
<Attention counts={sync.itemCounts} tripId={tripId} sync={sync} canManage={canManage} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<NothingBound canManage={canManage} />
|
||||
@@ -118,6 +137,22 @@ export default function DocSyncPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{reconnecting && (
|
||||
<DocSyncConnectModal
|
||||
provider={reconnecting}
|
||||
sync={sync}
|
||||
onClose={() => setReconnecting(null)}
|
||||
onConnected={id => {
|
||||
setReconnecting(null)
|
||||
// Run straight away, as binding does: the card still reports the
|
||||
// refusal until a run says otherwise, and the person has just
|
||||
// done the one thing that could change the answer.
|
||||
const rebound = sync.links.find(l => l.providerId === id)
|
||||
if (rebound) void sync.syncNow(rebound.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopeFor && sync.connectionFor(scopeFor) && (
|
||||
<DocSyncScopeModal
|
||||
connection={sync.connectionFor(scopeFor)!}
|
||||
@@ -267,10 +302,12 @@ function Attention({
|
||||
counts,
|
||||
tripId,
|
||||
sync,
|
||||
canManage,
|
||||
}: {
|
||||
counts: Record<string, number>
|
||||
tripId: number | string
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
canManage: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0)
|
||||
@@ -292,8 +329,10 @@ function Attention({
|
||||
<span className="mt-0.5 block text-caption text-content-muted">{t(`docsync.issues.${k}`)}</span>
|
||||
</span>
|
||||
{/* A conflict is the one of these a person can act on, so it is
|
||||
the one that opens. The rest are a state to read. */}
|
||||
{k === 'conflict' ? (
|
||||
the one that opens. The rest are a state to read. Only for
|
||||
the owner: the server refuses a member's choice, so a member
|
||||
gets the count, like every other row. */}
|
||||
{k === 'conflict' && canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConflicts(v => !v)}
|
||||
@@ -306,7 +345,7 @@ function Attention({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{k === 'conflict' && showConflicts && (
|
||||
{k === 'conflict' && canManage && showConflicts && (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{conflicts.items === null && (
|
||||
<li className="flex justify-center py-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-022
|
||||
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-023
|
||||
|
||||
/**
|
||||
* The folder picker of the document-sync dialog.
|
||||
@@ -364,4 +364,20 @@ describe('DocSyncScopeModal errors', () => {
|
||||
expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument()
|
||||
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-SCOPE-023: a listing request that fell over says so instead of spinning forever', async () => {
|
||||
// The store answers 200 with a code when it refuses, but a request that
|
||||
// never got an answer (a store behind a VPN that is down, a connection an
|
||||
// admin deleted meanwhile) rejected instead, nothing caught it, and the
|
||||
// spinner was all the picker ever showed.
|
||||
listScopes.mockRejectedValue({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
|
||||
render(<Harness />)
|
||||
|
||||
expect(await screen.findByText(S['docsync.error.unknown'])).toBeInTheDocument()
|
||||
expect(document.querySelector('.animate-spin')).toBeNull()
|
||||
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
|
||||
// Making a new folder is still on offer: the store may well be reachable
|
||||
// for that, and it is the way out when the listing is what failed.
|
||||
expect(createButton()).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-026
|
||||
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-030
|
||||
|
||||
/**
|
||||
* The document-sync hook.
|
||||
@@ -21,6 +21,8 @@ const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown)
|
||||
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
|
||||
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
|
||||
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
|
||||
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
|
||||
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
|
||||
|
||||
vi.mock('../../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../api/client')>()
|
||||
@@ -36,11 +38,13 @@ vi.mock('../../../api/client', async (importOriginal) => {
|
||||
testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data),
|
||||
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch),
|
||||
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
|
||||
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
|
||||
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useDocSync, canManageDocSync, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
|
||||
import { useDocSync, canManageDocSync, needsReauth, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
|
||||
|
||||
const TRIP = 3
|
||||
|
||||
@@ -120,6 +124,8 @@ beforeEach(() => {
|
||||
testConnectionApi.mockResolvedValue({ connected: true })
|
||||
updateLinkApi.mockResolvedValue({})
|
||||
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
|
||||
listScopesApi.mockResolvedValue({ scopes: [] })
|
||||
resolveApi.mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
describe('useDocSync initial load', () => {
|
||||
@@ -410,6 +416,71 @@ describe('useDocSync run and save feedback', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDocSync loadScopes', () => {
|
||||
it('FE-DOCSYNC-HOOK-027: a request that fell over still produces a listing the picker can render', async () => {
|
||||
// The picker awaits this inside an effect with nothing to catch, so a
|
||||
// rejection never reached `setScopes` and the spinner ran forever. A store
|
||||
// behind a VPN that is down answers this way every time.
|
||||
listScopesApi.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
let listing: unknown
|
||||
await act(async () => { listing = await result.current.loadScopes(9) })
|
||||
|
||||
expect(listing).toEqual({ scopes: [], error: 'unknown' })
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-HOOK-028: a refusal with a code keeps the code, a sentence does not become a key', async () => {
|
||||
const { result } = await mountLoaded()
|
||||
let listing: { scopes: unknown[]; error?: string } | undefined
|
||||
|
||||
listScopesApi.mockRejectedValueOnce({ response: { status: 400, data: { error: 'unknown_provider' } } })
|
||||
await act(async () => { listing = await result.current.loadScopes(9, 'tri') })
|
||||
expect(listing?.error).toBe('unknown_provider')
|
||||
expect(listScopesApi).toHaveBeenCalledWith(TRIP, 9, 'tri')
|
||||
|
||||
// The picker renders this as docsync.error.<value>, so prose must not
|
||||
// arrive there as a translation key.
|
||||
listScopesApi.mockRejectedValueOnce({ response: { status: 404, data: { message: 'Connection not found' } } })
|
||||
await act(async () => { listing = await result.current.loadScopes(9) })
|
||||
expect(listing?.error).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDocSync resolveConflict', () => {
|
||||
it('FE-DOCSYNC-HOOK-029: a refused choice says why instead of vanishing into a void call', async () => {
|
||||
// Both shells call this as `void conflicts.resolve(...)`. A member's choice
|
||||
// answers 403 and a row settled elsewhere answers 400; either way the
|
||||
// button came back and nothing said what happened.
|
||||
resolveApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
||||
const { result } = await mountLoaded()
|
||||
|
||||
let outcome: boolean | undefined
|
||||
await act(async () => { outcome = await result.current.resolveConflict(4, 'trek') })
|
||||
|
||||
expect(outcome).toBe(false)
|
||||
expect(result.current.error).toBe('forbidden')
|
||||
// reloaded, so the counts are the server's, and the message survived it
|
||||
expect(listLinksApi).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => { outcome = await result.current.resolveConflict(4, 'provider') })
|
||||
|
||||
expect(outcome).toBe(true)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(resolveApi).toHaveBeenLastCalledWith(TRIP, 4, 'provider')
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsReauth', () => {
|
||||
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
|
||||
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
|
||||
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })).toBe(true)
|
||||
expect(needsReauth({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })).toBe(true)
|
||||
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unreachable' })).toBe(false)
|
||||
expect(needsReauth({ lastSyncState: 'ok', lastSyncError: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('storeName', () => {
|
||||
it('FE-DOCSYNC-HOOK-024: prefers the provider list, then the name on the link, then the raw id', () => {
|
||||
const bound = link() as DocSyncLink
|
||||
|
||||
@@ -111,6 +111,19 @@ export function bindingNotice(
|
||||
return link.lastSyncError ? `${state} · ${t(`docsync.error.${link.lastSyncError}`)}` : state
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the store has refused the stored credential.
|
||||
*
|
||||
* Two shapes say so. A refusal while listing is recorded as its own state; a
|
||||
* refusal anywhere else (resolving the folder, one transfer) leaves the state
|
||||
* at failed or partial and puts the reason in the error code. Either way the
|
||||
* cure is the same, a new credential, and both shells decide from here
|
||||
* whether to offer the form for it.
|
||||
*/
|
||||
export function needsReauth(link: Pick<DocSyncLink, 'lastSyncState' | 'lastSyncError'>): boolean {
|
||||
return link.lastSyncState === 'needs_reauth' || link.lastSyncError === 'unauthorized'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this person may change a sync binding.
|
||||
*
|
||||
@@ -261,9 +274,22 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
/**
|
||||
* The containers a connection can be bound to.
|
||||
*
|
||||
* Always resolves to a listing, the way `testConnection` always resolves to
|
||||
* a verdict: the route answers 200 with an error code when the store refuses,
|
||||
* and a request that fell over on the way (a store behind a VPN that is not
|
||||
* up, a connection an admin deleted meanwhile) has to reach the picker the
|
||||
* same way. Left to reject, it never reached `setScopes`, and the picker
|
||||
* spun forever with nothing said.
|
||||
*/
|
||||
const loadScopes = useCallback(async (connectionId: number, q?: string) => {
|
||||
const res = await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
|
||||
return res
|
||||
try {
|
||||
return await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
|
||||
} catch (e: unknown) {
|
||||
return { scopes: [] as DocSyncScope[], error: readError(e) }
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
/**
|
||||
@@ -364,9 +390,27 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
}
|
||||
}, [tripId, load])
|
||||
|
||||
/**
|
||||
* Settle one conflict.
|
||||
*
|
||||
* Reports failure the way the other writes do. The rejection used to escape
|
||||
* into the `void` at the call site, so a refused choice (the row was settled
|
||||
* elsewhere a moment ago, or the server answered 403) put the button back
|
||||
* and left the row standing with nothing said.
|
||||
*/
|
||||
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
|
||||
await docsyncApi.resolve(tripId, itemId, keep)
|
||||
await load()
|
||||
setError(null)
|
||||
try {
|
||||
await docsyncApi.resolve(tripId, itemId, keep)
|
||||
await load()
|
||||
return true
|
||||
} catch (e: unknown) {
|
||||
// Reload first, so the counts match what the server has, then say why.
|
||||
// The other way round the reload cleared the message again.
|
||||
await load()
|
||||
setError(readError(e))
|
||||
return false
|
||||
}
|
||||
}, [tripId, load])
|
||||
|
||||
/** Providers the admin switched on that also have a working adapter. */
|
||||
|
||||
@@ -2165,6 +2165,118 @@ describe('MapViewGL', () => {
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
|
||||
// Both GL libraries take their clicks from the canvas container, and the handle sits
|
||||
// inside it, right on the route's hit band. Stopping pointerdown does not stop the click
|
||||
// that follows, so a click on a handle also read as a click on the road under it and
|
||||
// dropped a second via exactly there.
|
||||
it('FE-COMP-MAPVIEWGL-VIA-003: a click on a via handle is the handle\'s, not the road\'s', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-route-hit' ? { id } : null))
|
||||
const onRouteClick = vi.fn()
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
onRouteClick={onRouteClick}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild as HTMLElement
|
||||
|
||||
const reachedMap = vi.fn()
|
||||
glCanvasContainer.addEventListener('click', reachedMap)
|
||||
try {
|
||||
act(() => { handle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
||||
expect(reachedMap).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
glCanvasContainer.removeEventListener('click', reachedMap)
|
||||
}
|
||||
|
||||
// And the road's own handler leaves a click that came through the handle alone.
|
||||
const roadClick = layerHandler('click', 'trip-route-hit')
|
||||
expect(roadClick).toBeTypeOf('function')
|
||||
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: handle } }) })
|
||||
expect(onRouteClick).not.toHaveBeenCalled()
|
||||
act(() => { roadClick({ lngLat: { lat: 48.1, lng: 2.1 }, point: { x: 100, y: 80 }, originalEvent: { target: glCanvasContainer } }) })
|
||||
expect(onRouteClick).toHaveBeenCalledWith(48.1, 2.1)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-004: a right-click on a via handle removes it and opens nothing underneath', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
const onRemoveVia = vi.fn()
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
onRemoveVia={onRemoveVia}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild as HTMLElement
|
||||
|
||||
// The map's contextmenu is the add-place gesture; it must not fire for the spot the
|
||||
// via has just been removed from.
|
||||
const reachedMap = vi.fn()
|
||||
glCanvasContainer.addEventListener('contextmenu', reachedMap)
|
||||
try {
|
||||
act(() => { handle.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) })
|
||||
} finally {
|
||||
glCanvasContainer.removeEventListener('contextmenu', reachedMap)
|
||||
}
|
||||
|
||||
expect(onRemoveVia).toHaveBeenCalledWith(1, 5)
|
||||
expect(reachedMap).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-005: the handles go away below the zoom a via can be aimed at', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
// Country scale: a day's vias collapse into a heap of dots over one town, and a
|
||||
// short drag on one of them moves the route by kilometres.
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(8)
|
||||
try {
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
render(
|
||||
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild as HTMLElement
|
||||
expect(handle.style.display).toBe('none')
|
||||
|
||||
// Back in close enough, the zoom that settles brings them back.
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(12)
|
||||
act(() => {
|
||||
glMap.on.mock.calls
|
||||
.filter(c => c[0] === 'zoomend' && typeof c[1] === 'function')
|
||||
.forEach(c => (c[1] as () => void)())
|
||||
})
|
||||
expect(handle.style.display).toBe('block')
|
||||
} finally {
|
||||
vi.mocked(glMap.getZoom).mockReturnValue(10)
|
||||
}
|
||||
})
|
||||
// ── Satellite ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
|
||||
|
||||
@@ -845,15 +845,17 @@ export function MapViewGL({
|
||||
const VIA_MIN_ZOOM = 9
|
||||
const applyViaZoom = () => {
|
||||
const on = map.getZoom() >= VIA_MIN_ZOOM
|
||||
for (const pin of viaPinsRef.current) {
|
||||
const node = (pin as { getElement?: () => HTMLElement | null }).getElement?.()
|
||||
if (node) node.style.display = on ? '' : 'none'
|
||||
}
|
||||
// `el` is the one handle both pin paths expose; the library marker's own accessor
|
||||
// does not exist on the hand-positioned pin, so reading that left every handle on.
|
||||
for (const pin of viaPinsRef.current) pin.el.style.display = on ? 'block' : 'none'
|
||||
}
|
||||
|
||||
for (const via of vias) {
|
||||
const el = document.createElement('span')
|
||||
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
|
||||
// Marks the handle for the map's own click handlers: the drive's hit band runs
|
||||
// right under it, and a click that lands here is the handle's, not the road's.
|
||||
el.dataset.viaHandle = ''
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
|
||||
viaPinsRef.current.push(pin)
|
||||
if (!viasDraggable) continue
|
||||
@@ -918,6 +920,10 @@ export function MapViewGL({
|
||||
}
|
||||
const onContext = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
// Stopped here as well: both GL libraries listen on the canvas container the
|
||||
// handle sits in, and the map's own contextmenu opens the add-place form at
|
||||
// the very spot the via has just been removed from.
|
||||
e.stopPropagation()
|
||||
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
}
|
||||
el.addEventListener('pointerdown', onDown)
|
||||
@@ -928,6 +934,10 @@ export function MapViewGL({
|
||||
el.addEventListener('mousedown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('dblclick', swallow)
|
||||
// Stopping pointerdown does not stop the click that follows it, and the handle sits
|
||||
// on the route's hit band: the map's click landed on the road and dropped a second
|
||||
// via exactly under the one that was clicked.
|
||||
el.addEventListener('click', swallow)
|
||||
viaCleanupRef.current.push(() => {
|
||||
el.removeEventListener('pointerdown', onDown)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
@@ -937,6 +947,7 @@ export function MapViewGL({
|
||||
el.removeEventListener('mousedown', swallow)
|
||||
el.removeEventListener('touchstart', swallow)
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
el.removeEventListener('click', swallow)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1072,8 +1083,19 @@ export function MapViewGL({
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onRouteClick) return
|
||||
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
|
||||
const onClick = (e: {
|
||||
lngLat: { lat: number; lng: number }
|
||||
point?: { x: number; y: number }
|
||||
originalEvent?: { target?: EventTarget | null }
|
||||
preventDefault?: () => void
|
||||
}) => {
|
||||
e.preventDefault?.()
|
||||
// A layer handler only asks what is drawn under the point, not what element the
|
||||
// click came through. A via handle sits on the band by definition, and a click on
|
||||
// it must not read as a click on the road under it; the map-level handler below
|
||||
// filters markers the same way.
|
||||
const target = e.originalEvent?.target
|
||||
if (target instanceof Element && target.closest('[data-via-handle], .mapboxgl-marker, .maplibregl-marker')) return
|
||||
// Layer handlers are independent: MapLibre evaluates each registration
|
||||
// against the same click, so a point that hits both the offered-route band
|
||||
// and the current-route band fires both. Every alternative starts and ends
|
||||
|
||||
@@ -87,4 +87,39 @@ describe('TripRouteOverviewPanel', () => {
|
||||
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-009: says when legs could not be routed, and marks the day that is short', () => {
|
||||
// A refused leg stays a straight line and adds nothing to the sum, so a total shown
|
||||
// without a word about it reads as the whole trip when it is not.
|
||||
const days = overview().days
|
||||
const short = overview({
|
||||
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 0 }],
|
||||
unroutedLegs: 1,
|
||||
})
|
||||
render(<TripRouteOverviewPanel overview={short} unit="metric" />)
|
||||
|
||||
expect(screen.getByTestId('trip-overview-unrouted')).toHaveTextContent('map.overview.unrouted')
|
||||
expect(screen.getAllByRole('img', { name: 'map.overview.dayUnrouted' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-010: does not call a leg unrouted while the round is still running', () => {
|
||||
// Every leg still waiting is unrouted too; the ellipsis already says the number grows.
|
||||
const days = overview().days
|
||||
const running = overview({
|
||||
days: [{ ...days[0], unroutedLegs: 1 }, { ...days[1], unroutedLegs: 1 }],
|
||||
unroutedLegs: 2,
|
||||
loading: true,
|
||||
})
|
||||
render(<TripRouteOverviewPanel overview={running} unit="metric" />)
|
||||
|
||||
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
|
||||
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-011: a fully routed trip carries no warning at all', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview({ unroutedLegs: 0 })} unit="metric" />)
|
||||
|
||||
expect(screen.queryByTestId('trip-overview-unrouted')).toBeNull()
|
||||
expect(screen.queryByRole('img', { name: 'map.overview.dayUnrouted' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Route } from 'lucide-react'
|
||||
import { AlertTriangle, Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
|
||||
@@ -66,6 +66,9 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!overview.days.length) return null
|
||||
// Only once the round is over: while it runs every leg still waiting is unrouted too,
|
||||
// and the ellipsis on the total already says the number is growing.
|
||||
const unrouted = overview.loading ? 0 : (overview.unroutedLegs ?? 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -95,9 +98,23 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
|
||||
</span>
|
||||
</div>
|
||||
{/* A leg the router refused stays a straight line and adds nothing to the sum, so
|
||||
the total is short by however much road those legs are. Said in words rather than
|
||||
left to the reader to notice, because the number is what a fuel estimate starts from. */}
|
||||
{unrouted > 0 && (
|
||||
<div
|
||||
data-testid="trip-overview-unrouted"
|
||||
className="text-warning"
|
||||
style={{ display: 'flex', alignItems: 'flex-start', gap: 6, padding: '0 14px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
<AlertTriangle size={12} style={{ flexShrink: 0, marginTop: 1 }} aria-hidden />
|
||||
<span>{t('map.overview.unrouted', { count: unrouted })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
|
||||
{overview.days.map(day => {
|
||||
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
|
||||
const dayUnrouted = overview.loading ? 0 : (day.unroutedLegs ?? 0)
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
|
||||
@@ -110,6 +127,17 @@ export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelect
|
||||
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
|
||||
})}
|
||||
</span>
|
||||
{/* The day's own share of the shortfall, so the reader knows which figure
|
||||
to distrust rather than only that one of them is off. */}
|
||||
{dayUnrouted > 0 && (
|
||||
<AlertTriangle
|
||||
size={12}
|
||||
className="text-warning"
|
||||
style={{ flexShrink: 0 }}
|
||||
role="img"
|
||||
aria-label={t('map.overview.dayUnrouted', { count: dayUnrouted })}
|
||||
/>
|
||||
)}
|
||||
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDistance(day.distance / 1000, unit)}
|
||||
</span>
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock('./RouteCalculator', async (importActual) => {
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
||||
})
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator')
|
||||
const { calculateRouteWithLegs, RoutingRefusedError } = await import('./RouteCalculator')
|
||||
|
||||
const leg = (distance: number): RouteSegment => ({
|
||||
mid: [0, 0], from: [0, 0], to: [0, 0], distance, duration: 600,
|
||||
@@ -104,4 +104,112 @@ describe('tripRouteGeometry', () => {
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ totalDistance: 0 })
|
||||
})
|
||||
|
||||
// The public OSRM hosts allow about one request a second. Every leg of every day at
|
||||
// once is exactly the burst they answer with 429 from the second second on, and each
|
||||
// refused leg used to stay a straight line with no distance and no word about it.
|
||||
it('FE-MAP-TRG-007: asks the router one leg at a time, a second apart', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
// A round trip takes time; only a network answer is paced, a cache hit is not.
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(() => new Promise(resolve => {
|
||||
setTimeout(() => resolve({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] }), 100)
|
||||
}))
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
|
||||
// The second day waits for the first to answer...
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
// ...and then for the gap the shared hosts need between two requests.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1100)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
const summary = await pending
|
||||
expect(summary.totalDistance).toBe(20000)
|
||||
expect(summary.unroutedLegs).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-008: a rate limit is waited out and the leg asked for again', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(1100)
|
||||
const summary = await pending
|
||||
|
||||
// Both days routed: the refused leg answered on its second try.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(3)
|
||||
expect(summary.totalDistance).toBe(20000)
|
||||
expect(summary.unroutedLegs).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-009: a host that says how long to wait is given that long', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, 6000))
|
||||
.mockResolvedValue({ coordinates: [[48.86, 2.35], [48.90, 2.42]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
|
||||
// The own first backoff would be 1500 ms; asking then is just a second refusal.
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(4000)
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1100)
|
||||
await expect(pending).resolves.toMatchObject({ totalDistance: 20000 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-010: a refusal the host meant is not repeated, and the leg is counted as unrouted', async () => {
|
||||
// A 400 objects to these coordinates; the same request earns the same answer.
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
||||
.mockResolvedValue({ coordinates: [[45.76, 4.83], [45.80, 4.90]], distance: 10000, duration: 600, legs: [leg(10000)] })
|
||||
|
||||
const summary = await routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
// The first day keeps its straight line and adds nothing to the sum...
|
||||
expect(summary.lines[0]).toEqual([[48.86, 2.35], [48.90, 2.42]])
|
||||
expect(summary.days[0].distance).toBe(0)
|
||||
expect(summary.totalDistance).toBe(10000)
|
||||
// ...and says so, per day and for the trip, so the total is not mistaken for complete.
|
||||
expect(summary.days.map(d => d.unroutedLegs)).toEqual([1, 0])
|
||||
expect(summary.unroutedLegs).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRG-011: a rate limit that never lifts gives up after the retries and is counted', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null))
|
||||
const pending = routeTrip(input, { profile: 'driving', tripId: 7 })
|
||||
|
||||
// Two backoffs per leg, a gap between the legs, then it stops asking.
|
||||
await vi.advanceTimersByTimeAsync(20000)
|
||||
const summary = await pending
|
||||
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(6)
|
||||
expect(summary.totalDistance).toBe(0)
|
||||
expect(summary.unroutedLegs).toBe(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
@@ -21,6 +21,12 @@ export interface TripOverviewDay {
|
||||
duration: number
|
||||
/** The modes this day is actually travelled in, first use first. */
|
||||
modes: string[]
|
||||
/**
|
||||
* Legs drawn as a straight line because the router gave no road for them: not asked
|
||||
* yet while the round is running, refused or cut off by the deadline once it is over.
|
||||
* Those legs add nothing to `distance`, so a day with any of them reads too short.
|
||||
*/
|
||||
unroutedLegs?: number
|
||||
}
|
||||
|
||||
export interface TripRouteSummary {
|
||||
@@ -34,6 +40,8 @@ export interface TripRouteSummary {
|
||||
focusPoints: [number, number][]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
/** `unroutedLegs` over every day: above zero, `totalDistance` is a partial sum. */
|
||||
unroutedLegs?: number
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
@@ -44,6 +52,8 @@ interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
export interface TripRoutePlanDay { day: Day; runs: Chunk[][] }
|
||||
|
||||
type Answer = { coordinates: [number, number][]; legs: RouteSegment[] } | null
|
||||
/** Every leg's answer so far, indexed like the plan: day, run, chunk. */
|
||||
export type TripRouteAnswers = Answer[][][]
|
||||
|
||||
/** The palette entry a day keeps, whatever else is added to the trip around it. */
|
||||
export const dayRouteColor = (day: Day): { line: string; casing: string } =>
|
||||
@@ -90,10 +100,12 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
|
||||
return plan.map(({ day, runs }, d) => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
let unroutedLegs = 0
|
||||
runs.forEach((chunks, r) => {
|
||||
const polyline: [number, number][] = []
|
||||
chunks.forEach((chunk, c) => {
|
||||
const answer = routed[d]?.[r]?.[c]
|
||||
if (!answer) unroutedLegs++
|
||||
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
|
||||
for (const point of coords) {
|
||||
// Drop the point shared with the previous chunk so concatenated legs
|
||||
@@ -119,6 +131,7 @@ export function assembleTripRoute(plan: TripRoutePlanDay[], routed: Answer[][][]
|
||||
distance: segments.reduce((sum, s) => sum + s.distance, 0),
|
||||
duration: segments.reduce((sum, s) => sum + s.duration, 0),
|
||||
modes,
|
||||
unroutedLegs,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -133,18 +146,49 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
focusPoints: lines.flat(),
|
||||
totalDistance: days.reduce((sum, d) => sum + d.distance, 0),
|
||||
totalDuration: days.reduce((sum, d) => sum + d.duration, 0),
|
||||
unroutedLegs: days.reduce((sum, d) => sum + (d.unroutedLegs ?? 0), 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, a few at a time.
|
||||
* Gap between two routing requests. The public OSRM hosts TREK ships with state one
|
||||
* request per second; the overview asks for every leg of every day, so without spacing
|
||||
* the first handful answer and the rest come back 429 (the road trip rail learnt this
|
||||
* first and paces itself the same way).
|
||||
*/
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
*
|
||||
* A fortnight is dozens of legs and the routing host is usually a shared OSRM, so the
|
||||
* pool is deliberately small — the same one the sidebar connectors use. RouteCalculator's
|
||||
* cache is shared with the day route, so a day already drawn on screen costs nothing.
|
||||
* A fortnight is dozens of legs and the routing host is usually a shared OSRM that
|
||||
* refuses a burst, so the legs go out one after another with a pause between them, and
|
||||
* a rate limit is waited out (for as long as the host asks, when it says) and asked
|
||||
* again. Only what actually went to the network is paced: RouteCalculator's cache is
|
||||
* shared with the day route, so a day already drawn on screen costs nothing and waits
|
||||
* for nothing.
|
||||
*
|
||||
* A leg the router refuses keeps its straight line and contributes no distance, exactly
|
||||
* as a failed leg of a day route does. Nothing here throws.
|
||||
* A leg the router still refuses keeps its straight line and contributes no distance,
|
||||
* exactly as a failed leg of a day route does; `assembleTripRoute` counts it so the
|
||||
* caller can say the total is short. A refusal the host meant (these coordinates, this
|
||||
* profile) is not repeated, because the same request earns the same answer. Nothing
|
||||
* here throws.
|
||||
*/
|
||||
export async function routeTripLegs(
|
||||
plan: TripRoutePlanDay[],
|
||||
@@ -161,26 +205,37 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused (usually a rate limit) — the straight line stands.
|
||||
const waypoints = chunk.points.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < tasks.length && !signal?.aborted) await tasks[next++]()
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
if (signal?.aborted) break
|
||||
const startedAt = performance.now()
|
||||
await tasks[i]()
|
||||
const wasNetwork = performance.now() - startedAt > CACHE_HIT_MS
|
||||
if (wasNetwork && i < tasks.length - 1) await sleep(REQUEST_SPACING_MS, signal)
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
|
||||
return routed
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
@@ -10,7 +10,9 @@ vi.mock('./RouteCalculator', async (importActual) => {
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
||||
})
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator')
|
||||
const { calculateRouteWithLegs, RoutingRefusedError } = await import('./RouteCalculator')
|
||||
|
||||
type RouteAnswer = Awaited<ReturnType<typeof calculateRouteWithLegs>>
|
||||
|
||||
const leg = (distance: number, duration: number): RouteSegment => ({
|
||||
mid: [0, 0], from: [0, 0], to: [0, 0],
|
||||
@@ -136,4 +138,76 @@ describe('useTripRouteOverview', () => {
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-009: a leg the router refused outright is counted, so the total is not taken for complete', async () => {
|
||||
// A 400 objects to these coordinates; the leg stays a straight line with no distance.
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValueOnce(new RoutingRefusedError(400, null))
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 3000 })
|
||||
expect(result.current.days.map(d => d.unroutedLegs)).toEqual([1, 0])
|
||||
expect(result.current.unroutedLegs).toBe(1)
|
||||
expect(result.current.totalDistance).toBe(12000)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-010: each day is drawn as soon as its road answers, not when the round is over', async () => {
|
||||
// The legs go to the router one at a time with a pause between them, so a cold trip
|
||||
// takes a second per leg; roads filling in one by one is what makes that read as
|
||||
// progress rather than as a hang.
|
||||
const answer: RouteAnswer = {
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
distance: 12000, duration: 900,
|
||||
legs: [leg(12000, 900)],
|
||||
}
|
||||
const answers: Array<(value: RouteAnswer) => void> = []
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(
|
||||
() => new Promise<RouteAnswer>(resolve => { answers.push(resolve) }),
|
||||
)
|
||||
const { result } = render()
|
||||
|
||||
// Only the first leg has been asked for.
|
||||
expect(answers).toHaveLength(1)
|
||||
await act(async () => { answers[0](answer) })
|
||||
|
||||
// Its day is on the map while the second is still waiting.
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.days[0].distance).toBe(12000)
|
||||
expect(result.current.days[1].distance).toBe(0)
|
||||
|
||||
await waitFor(() => expect(answers).toHaveLength(2), { timeout: 3000 })
|
||||
await act(async () => { answers[1](answer) })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.totalDistance).toBe(24000)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-011: keeps the frame it set on the straight lines until the round is over', async () => {
|
||||
// A fresh `focusPoints` array is what tells the map to fit the camera. One per
|
||||
// answering leg would take the map back from wherever the reader has panned to, so
|
||||
// the frame changes exactly twice: on the straight lines, and on the finished roads.
|
||||
const answer: RouteAnswer = {
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
distance: 12000, duration: 900,
|
||||
legs: [leg(12000, 900)],
|
||||
}
|
||||
const answers: Array<(value: RouteAnswer) => void> = []
|
||||
vi.mocked(calculateRouteWithLegs).mockImplementation(
|
||||
() => new Promise<RouteAnswer>(resolve => { answers.push(resolve) }),
|
||||
)
|
||||
const { result } = render()
|
||||
const frame = result.current.focusPoints
|
||||
expect(frame.length).toBeGreaterThan(0)
|
||||
|
||||
await act(async () => { answers[0](answer) })
|
||||
// The road is drawn, the camera stays put.
|
||||
expect(result.current.days[0].distance).toBe(12000)
|
||||
expect(result.current.focusPoints).toBe(frame)
|
||||
|
||||
await waitFor(() => expect(answers).toHaveLength(2), { timeout: 3000 })
|
||||
await act(async () => { answers[1](answer) })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// The finished result frames the roads themselves, which have more points than the
|
||||
// straight lines had.
|
||||
expect(result.current.focusPoints).not.toBe(frame)
|
||||
expect(result.current.focusPoints.length).toBeGreaterThan(frame.length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type RouteProfileKey } from './RouteCalculator'
|
||||
import {
|
||||
assembleTripRoute, emptyAnswers, planTripRoute, routeTripLegs, summariseTripRoute,
|
||||
type TripRouteSummary,
|
||||
type TripRouteAnswers, type TripRouteSummary,
|
||||
} from './tripRouteGeometry'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
@@ -66,12 +66,25 @@ export function useTripRouteOverview(
|
||||
|
||||
// Straight lines first so the shape of the trip is on screen immediately, then the
|
||||
// real roads replace them — the same two-step the single-day route draws with.
|
||||
setResult({ ...summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan))), loading: true })
|
||||
const first = summariseTripRoute(assembleTripRoute(plan, emptyAnswers(plan)))
|
||||
setResult({ ...first, loading: true })
|
||||
|
||||
routeTripLegs(plan, { tripId, signal: controller.signal }).then(routed => {
|
||||
// Published leg by leg: the legs go to the router one at a time with a pause between
|
||||
// them, so a cold trip takes a second per leg, and roads that fill in as they answer
|
||||
// read as progress where straight lines that all flip at once read as a hang.
|
||||
//
|
||||
// The frame is the exception. A fresh `focusPoints` array is what tells the map to
|
||||
// fit the camera, and a fit every second would take the map back from wherever the
|
||||
// reader has panned to. The frame set on the straight lines therefore holds until
|
||||
// the round is over, and only the finished result brings a new one, exactly the two
|
||||
// fits the overview made when every answer landed at once.
|
||||
const publish = (routed: TripRouteAnswers, loading: boolean): void => {
|
||||
if (controller.signal.aborted) return
|
||||
setResult({ ...summariseTripRoute(assembleTripRoute(plan, routed)), loading: false })
|
||||
})
|
||||
const next = summariseTripRoute(assembleTripRoute(plan, routed))
|
||||
setResult({ ...next, focusPoints: loading ? first.focusPoints : next.focusPoints, loading })
|
||||
}
|
||||
routeTripLegs(plan, { tripId, signal: controller.signal, onAnswer: routed => publish(routed, true) })
|
||||
.then(routed => publish(routed, false))
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey is derived from the same inputs as plan, so keying on the string is
|
||||
|
||||
@@ -4299,6 +4299,104 @@ describe('DayPlanSidebar', () => {
|
||||
fireEvent.dragEnd(row)
|
||||
expect(row.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
// ── Drop positions count the rows the list hides ───────────────────────
|
||||
// The stop a booked night wrote is in the day but not in the list. The store
|
||||
// splices into the full day, so a position counted over the visible rows would
|
||||
// land one slot early and reorder would persist it that way.
|
||||
|
||||
/** A day whose first stored row is the hotel a booking put there, hidden from the list. */
|
||||
function dayWithHiddenHotel(dayId: number) {
|
||||
const hotel = buildPlace({ id: 600, name: 'Hotel Adlon', stop_type: 'hotel' })
|
||||
const lunch = buildPlace({ id: 601, name: 'Lunch' })
|
||||
const museum = buildPlace({ id: 602, name: 'Museum' })
|
||||
return {
|
||||
places: [hotel, lunch, museum],
|
||||
assignments: [
|
||||
buildAssignment({ id: 400, day_id: dayId, order_index: 0, place: hotel, accommodation_id: 7 } as never),
|
||||
buildAssignment({ id: 401, day_id: dayId, order_index: 1, place: lunch }),
|
||||
buildAssignment({ id: 402, day_id: dayId, order_index: 2, place: museum }),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-210: a place dropped on a row lands ahead of that row in the stored day', () => {
|
||||
const onAssignToDay = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const { places, assignments } = dayWithHiddenHotel(10)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: { '10': assignments }, onAssignToDay,
|
||||
})} />)
|
||||
;(window as any).__dragData = { placeId: '55' }
|
||||
fireEvent.drop(dragRow(screen.getByText('Museum')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
// Museum is the second visible row but the third stored one.
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(55, 10, 2)
|
||||
|
||||
;(window as any).__dragData = { placeId: '55' }
|
||||
fireEvent.drop(dragRow(screen.getByText('Lunch')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(onAssignToDay).toHaveBeenLastCalledWith(55, 10, 1)
|
||||
;(window as any).__dragData = null
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-211: a stop moved from another day onto a row lands ahead of that row in the stored day', () => {
|
||||
const moveAssignment = vi.fn(async () => undefined)
|
||||
stubTripActions({ moveAssignment })
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
]
|
||||
const source = buildPlace({ id: 1, name: 'Source place' })
|
||||
const { places, assignments } = dayWithHiddenHotel(11)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days, places: [source, ...places],
|
||||
assignments: { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place: source })], '11': assignments },
|
||||
})} />)
|
||||
fireEvent.dragStart(dragRow(screen.getByText('Source place')), { dataTransfer: emptyDataTransfer })
|
||||
fireEvent.drop(dragRow(screen.getByText('Museum')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 2)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-212: a place dropped on a note lands ahead of the next stop below it, or last', () => {
|
||||
const onAssignToDay = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const { places, assignments } = dayWithHiddenHotel(10)
|
||||
mockDayNotesState.dayNotes = { '10': [
|
||||
buildDayNote({ id: 70, day_id: 10, text: 'Between note', sort_order: 1.5 }),
|
||||
buildDayNote({ id: 71, day_id: 10, text: 'Closing note', sort_order: 5 }),
|
||||
] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places, assignments: { '10': assignments }, onAssignToDay,
|
||||
})} />)
|
||||
;(window as any).__dragData = { placeId: '55' }
|
||||
fireEvent.drop(cardRow(screen.getByText('Between note')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(onAssignToDay).toHaveBeenCalledWith(55, 10, 2)
|
||||
|
||||
// Nothing below the closing note, so the stop goes to the end of the stored
|
||||
// day: three rows, the hidden hotel among them.
|
||||
;(window as any).__dragData = { placeId: '55' }
|
||||
fireEvent.drop(cardRow(screen.getByText('Closing note')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(onAssignToDay).toHaveBeenLastCalledWith(55, 10, 3)
|
||||
;(window as any).__dragData = null
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-213: a stop moved from another day onto a note lands ahead of the next stop below it', () => {
|
||||
const moveAssignment = vi.fn(async () => undefined)
|
||||
stubTripActions({ moveAssignment })
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
]
|
||||
const source = buildPlace({ id: 1, name: 'Source place' })
|
||||
const { places, assignments } = dayWithHiddenHotel(11)
|
||||
mockDayNotesState.dayNotes = { '11': [buildDayNote({ id: 70, day_id: 11, text: 'Between note', sort_order: 1.5 })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days, places: [source, ...places],
|
||||
assignments: { '10': [buildAssignment({ id: 11, day_id: 10, order_index: 0, place: source })], '11': assignments },
|
||||
})} />)
|
||||
fireEvent.dragStart(dragRow(screen.getByText('Source place')), { dataTransfer: emptyDataTransfer })
|
||||
fireEvent.drop(cardRow(screen.getByText('Between note')), { dataTransfer: { getData: vi.fn(() => '') } })
|
||||
expect(moveAssignment).toHaveBeenCalledWith(1, 11, 10, 11, 2)
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5DPS-001 to FE-W5DPS-006 — booking subtitles, the collections entry in the
|
||||
|
||||
@@ -494,6 +494,26 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
getDisplayTime: getDisplayTimeForDay,
|
||||
})
|
||||
|
||||
// The list is a filtered view of the day, and the store is not: assignPlaceToDay
|
||||
// and moveAssignment splice into the full day, hidden rows included, and persist
|
||||
// the result. A position counted over the rows on screen therefore lands one slot
|
||||
// early for every hidden row above the target. So a drop names the row it should
|
||||
// land ahead of, and what goes to the store is that row's slot in the full day,
|
||||
// in the order the list reads it. No row (an empty day, a note under the last
|
||||
// stop) means the end of the full day.
|
||||
const storedPositionBefore = (dayId: number, target: { id: number } | null | undefined): number => {
|
||||
const stored = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const idx = target ? stored.findIndex(a => a.id === target.id) : -1
|
||||
return idx >= 0 ? idx : stored.length
|
||||
}
|
||||
|
||||
// The stop a drop on a note lands ahead of: the next place below the note.
|
||||
const placeBelowNote = (dayId: number, noteId: number): Assignment | undefined => {
|
||||
const tm = getMergedItems(dayId)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
||||
return noteIdx < 0 ? undefined : tm.slice(noteIdx + 1).find(i => i.type === 'place')?.data
|
||||
}
|
||||
|
||||
// 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
|
||||
const mergedItemsMap = useMemo(() => {
|
||||
@@ -1190,6 +1210,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
computeTransportPosition,
|
||||
initTransportPositions,
|
||||
getMergedItems,
|
||||
storedPositionBefore,
|
||||
placeBelowNote,
|
||||
mergedItemsMap,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
@@ -1388,6 +1410,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
computeTransportPosition,
|
||||
initTransportPositions,
|
||||
getMergedItems,
|
||||
storedPositionBefore,
|
||||
placeBelowNote,
|
||||
mergedItemsMap,
|
||||
applyMergedOrder,
|
||||
handleMergedDrop,
|
||||
@@ -2017,8 +2041,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
onAssignToDay?.(Number.parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
||||
onAssignToDay?.(Number.parseInt(placeId), day.id, storedPositionBefore(day.id, assignment))
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
@@ -2027,8 +2050,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, assignment)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -2617,12 +2639,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
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?.(Number.parseInt(placeId), day.id, pos)
|
||||
// assignments at the note's position (ahead of the first
|
||||
// place below it), so it lands right where the note sits.
|
||||
onAssignToDay?.(Number.parseInt(placeId), day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id)))
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
@@ -2639,10 +2658,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, storedPositionBefore(day.id, placeBelowNote(day.id, note.id))).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
|
||||
@@ -1498,4 +1498,50 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByRole('button', { name: /remove from day/i })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /add to day/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-017c: the booked stop stays off the buttons when the day list hides it', () => {
|
||||
// The day view hands the inspector the day without the stop a booking wrote,
|
||||
// while the store still holds it. Judged on the list alone the hotel looked
|
||||
// unassigned, and "Add to day" put a second copy beside the booked night.
|
||||
const booked = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 }];
|
||||
seedStore(useTripStore, { assignments: { '1': booked } as never });
|
||||
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
|
||||
|
||||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||||
expect(screen.queryByText('Remove from Day')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-017d: a stop the traveller placed at the booked hotel can still be removed', () => {
|
||||
// A second copy that did get onto the day is the traveller's own row and shows
|
||||
// in the list. That one keeps its "Remove from day", which is the way back.
|
||||
const onRemoveAssignment = vi.fn();
|
||||
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null, accommodation_id: 4 };
|
||||
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 1, notes: null };
|
||||
seedStore(useTripStore, { assignments: { '1': [booked, own] } as never });
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
assignments={{ '1': [own] } as never}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Remove from Day').closest('button')!);
|
||||
expect(onRemoveAssignment).toHaveBeenCalledWith(1, 100);
|
||||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-017e: any booked stop at the place keeps the buttons off, whichever row the store lists first', () => {
|
||||
// With the service stops switched off the day list can hide a hotel the traveller
|
||||
// placed as well, so the store may list that row ahead of the booked one. The
|
||||
// question is whether a booking holds this place on this day, not which row comes first.
|
||||
const own = { id: 100, place, day_id: 1, place_id: place.id, order_index: 0, notes: null };
|
||||
const booked = { id: 99, place, day_id: 1, place_id: place.id, order_index: 1, notes: null, accommodation_id: 4 };
|
||||
seedStore(useTripStore, { assignments: { '1': [own, booked] } as never });
|
||||
render(<PlaceInspector {...defaultProps} selectedDayId={1} assignments={{ '1': [] }} />);
|
||||
|
||||
expect(screen.queryByText('Add to Day')).toBeNull();
|
||||
expect(screen.queryByText('Remove from Day')).toBeNull();
|
||||
});
|
||||
})
|
||||
|
||||
@@ -209,6 +209,12 @@ export default function PlaceInspector({
|
||||
const { t, locale, language } = useTranslation()
|
||||
// Currency-less prices mean "the trip's currency"; null in collection mode (EUR fallback below).
|
||||
const tripCurrency = useTripStore(s => s.trip?.currency)
|
||||
// The day list handed in is the one the planner shows, and in the day view that
|
||||
// list leaves out the stop a booking wrote. The store still holds it, so the
|
||||
// booked-night check below reads the day from there as well: on the list alone
|
||||
// the hotel of a booked night looked unassigned and could be put on the day a
|
||||
// second time, which is the duplicate that check exists to prevent.
|
||||
const storedDayAssignments = useTripStore(s => (selectedDayId ? s.assignments[String(selectedDayId)] : undefined))
|
||||
const toast = useToast()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
|
||||
@@ -322,7 +328,9 @@ export default function PlaceInspector({
|
||||
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||
: null
|
||||
/** This stop belongs to a booked night rather than to the traveller. */
|
||||
const bookedNight = assignmentInDay?.accommodation_id != null
|
||||
const bookedNight = assignmentInDay
|
||||
? assignmentInDay.accommodation_id != null
|
||||
: !!storedDayAssignments?.some(a => a.place?.id === place.id && a.accommodation_id != null)
|
||||
|
||||
// The weekday lines are display text; the ring is computed from the structured
|
||||
// periods next to them, in the place's own timezone. open_now stays the fallback.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-051
|
||||
// FE-PLANNER-PSHOOK-001 to FE-PLANNER-PSHOOK-052
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render, screen, fireEvent, act, waitFor } from '../../../tests/helpers/render';
|
||||
@@ -333,6 +333,21 @@ describe('usePlacesSidebar day helpers', () => {
|
||||
expect(S.inDaySet.size).toBe(0);
|
||||
expect(S.isAssignedToSelectedDay(5)).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PSHOOK-052: the stop a booking wrote counts as in the day when the list hides it', () => {
|
||||
// The day view hands the pool the day without the stop a booking wrote, while
|
||||
// the store still holds it. Judged on the list alone the hotel offered "add to
|
||||
// day" on its own check-in day, and that put a second row beside the night.
|
||||
const hotel = buildPlace({ id: 5, name: 'Hotel' });
|
||||
const loose = buildPlace({ id: 6, name: 'Loose' });
|
||||
const booked = buildAssignment({ place: hotel, day_id: 3, accommodation_id: 4 });
|
||||
seedStore(useTripStore, { assignments: { '3': [booked] } });
|
||||
render(<Host {...makeProps({ places: [hotel, loose], assignments: { '3': [] }, selectedDayId: 3 })} />);
|
||||
|
||||
expect([...S.inDaySet]).toEqual([5]);
|
||||
expect(S.isAssignedToSelectedDay(5)).toBe(true);
|
||||
expect(S.isAssignedToSelectedDay(6)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scroll restoration and row refs ───────────────────────────────────────────
|
||||
|
||||
@@ -307,16 +307,24 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
|
||||
}, [filtered, props.selectedPlaceId])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
||||
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
||||
|
||||
// The day list handed in is the one the planner shows, and in the day view that
|
||||
// list leaves out the stop a booking wrote. The store still holds it, so the set
|
||||
// that decides between "in the day" and the add button reads the day from there
|
||||
// as well: on the list alone the hotel of a booked night offered "add to day" on
|
||||
// its own check-in day, and taking that offer put a second row beside the night.
|
||||
const storedDayAssignments = useTripStore((s) => (selectedDayId ? s.assignments[String(selectedDayId)] : undefined))
|
||||
|
||||
const inDaySet = useMemo(() => {
|
||||
if (!selectedDayId) return new Set<number>()
|
||||
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
}, [assignments, selectedDayId])
|
||||
const ids = new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
for (const a of storedDayAssignments ?? []) if (a.place?.id) ids.add(a.place.id)
|
||||
return ids
|
||||
}, [assignments, storedDayAssignments, selectedDayId])
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) => inDaySet.has(placeId)
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||
const selDayId = selectedDayIdRef.current
|
||||
|
||||
@@ -145,14 +145,17 @@ describe('RoadtripLimitsCard', () => {
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
// `onSave` absent is how the caller says the reader may look but not change.
|
||||
open(undefined)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
// Nothing to assert but the absence of a crash: the commit path has no
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
// Disabled rather than accepting a number it would then drop: a field that took 400
|
||||
// and sprang back to 180 on Enter, with no word about why, read as broken.
|
||||
for (const field of inputs()) expect(field).toBeDisabled()
|
||||
// What is stored is still there to be read.
|
||||
expect(screen.getByTestId('limit-legMinutes')).toHaveValue(180)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-009: naming the kind of car brings out its own figures', () => {
|
||||
@@ -275,6 +278,42 @@ describe('RoadtripLimitsCard', () => {
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_connect_days', false)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A member without the right to edit days gets the dialog without a way to save. Every
|
||||
* control has to say so by being disabled: a switch that flips and stays off, or a
|
||||
* field that takes a number and springs back, is a fault report waiting to happen.
|
||||
*/
|
||||
describe('read-only', () => {
|
||||
it('FE-ROADTRIP-LIMITS-016: the kind of car and its figures cannot be changed', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_vehicle: 'electric', roadtrip_battery_kwh: 58, distance_unit: 'metric' } as never,
|
||||
})
|
||||
open(undefined)
|
||||
|
||||
for (const name of ['Either', 'Petrol', 'Electric']) {
|
||||
expect(screen.getByRole('radio', { name })).toBeDisabled()
|
||||
}
|
||||
// The stored figure holds the disclosure open, so the car's own rows are on show.
|
||||
expect(screen.getByTestId('limit-batteryKwh')).toBeDisabled()
|
||||
expect(screen.getByTestId('limit-batteryKwh')).toHaveValue(58)
|
||||
expect(screen.getByTestId('limit-fill')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-017: the switches over the route line are not offered', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_connect_days: true, distance_unit: 'metric' } as never,
|
||||
})
|
||||
open(undefined)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Connect the days' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'A colour per day' })).not.toBeInTheDocument()
|
||||
// The rows stay in the dialog, the same way the service-stop and hazard rows do
|
||||
// without a way to save: a switch withheld is not a setting that vanished.
|
||||
expect(screen.getByText('Connect the days')).toBeInTheDocument()
|
||||
expect(screen.getByText('A colour per day')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('../../hooks/useRoadtripSettings', () => ({
|
||||
|
||||
@@ -52,7 +52,7 @@ function parseLimit(raw: string): number {
|
||||
* its shape either way, because a range that switched between an input and a line of text
|
||||
* would move every row under it the moment a consumption was typed.
|
||||
*/
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, testId, onDraft, onChange }: {
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived, disabled, testId, onDraft, onChange }: {
|
||||
icon: typeof Clock
|
||||
label: string
|
||||
suffix: string
|
||||
@@ -60,6 +60,12 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
|
||||
placeholder: string
|
||||
step?: number
|
||||
derived?: string
|
||||
/**
|
||||
* Readable but not editable, which is how a reader without the right to change the
|
||||
* settings sees the dialog. Nothing this row could commit would be saved, and a field
|
||||
* that takes a number and springs back to the old one reads as broken.
|
||||
*/
|
||||
disabled?: boolean
|
||||
/**
|
||||
* A stable handle for the tests, because the rows are no longer at fixed indices.
|
||||
*
|
||||
@@ -133,6 +139,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
|
||||
placeholder={placeholder}
|
||||
aria-label={`${label}, ${suffix}`}
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onChange={e => { setDraft(e.target.value); onDraft?.(parseLimit(e.target.value)) }}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
@@ -141,7 +148,7 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, step, derived
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
// The spinners are the other half of the width problem: they eat a third of a
|
||||
// narrow field and nobody sets a driving limit by clicking an arrow.
|
||||
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
className="w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-end text-body tabular-nums text-content [appearance:textfield] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
@@ -269,6 +276,11 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = unit === 'imperial' ? 'imperial' : 'metric'
|
||||
const imperial = distanceUnit === 'imperial'
|
||||
// No way to save is the caller saying the reader may look but not change. Every
|
||||
// control below is disabled on that one answer: a member without the right to edit
|
||||
// days gets the dialog with the trip's figures in it, not a form that takes a number
|
||||
// and drops it.
|
||||
const readOnly = !onSave
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
@@ -448,6 +460,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
disabled={readOnly}
|
||||
testId="limit-legMinutes"
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
@@ -457,6 +470,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
disabled={readOnly}
|
||||
testId="limit-dayMinutes"
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
@@ -478,7 +492,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
icon={icon}
|
||||
label={label}
|
||||
on={avoiding.includes(cls)}
|
||||
disabled={!canAvoid}
|
||||
disabled={!canAvoid || readOnly}
|
||||
onToggle={() => toggleAvoid(cls)}
|
||||
/>
|
||||
))}
|
||||
@@ -486,7 +500,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
|
||||
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
|
||||
on={settings.roadtrip_service_stops_in_days !== false}
|
||||
disabled={!onSave}
|
||||
disabled={readOnly}
|
||||
onToggle={() => onSave?.('roadtrip_service_stops_in_days', settings.roadtrip_service_stops_in_days === false)}
|
||||
/>
|
||||
</Panel>
|
||||
@@ -516,6 +530,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
name="roadtrip-vehicle"
|
||||
className="peer sr-only"
|
||||
checked={(settings.roadtrip_vehicle || '') === (key ?? '')}
|
||||
disabled={readOnly}
|
||||
onChange={() => {
|
||||
// Never writes the car's figures away: switching kind leaves
|
||||
// both sets stored, `rangeFromSpec` simply returns null for
|
||||
@@ -526,7 +541,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
onSave?.('roadtrip_vehicle', key ?? '')
|
||||
}}
|
||||
/>
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent">
|
||||
<span className="flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-lg px-2 py-1.5 text-center text-body text-content-secondary transition-colors hover:bg-surface-hover peer-checked:bg-accent peer-checked:text-accent-text peer-focus-visible:ring-2 peer-focus-visible:ring-accent peer-disabled:pointer-events-none">
|
||||
<Icon size={14} className="shrink-0" aria-hidden />
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
@@ -555,6 +570,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
derived={computedKm ? t('roadtrip.limit.computed') : undefined}
|
||||
disabled={readOnly}
|
||||
testId="limit-range"
|
||||
onDraft={v => setPreview({ key: 'range', value: v })}
|
||||
onChange={setRange}
|
||||
@@ -569,6 +585,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
suffix="%"
|
||||
value={settings.roadtrip_fill_percent}
|
||||
placeholder={t('roadtrip.limit.fillFull')}
|
||||
disabled={readOnly}
|
||||
testId="limit-fill"
|
||||
onDraft={v => setPreview({ key: 'fill', value: v > 100 ? 100 : v })}
|
||||
onChange={v => onSave?.('roadtrip_fill_percent', v > 100 ? 100 : v)}
|
||||
@@ -611,6 +628,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
step={step}
|
||||
value={showSpec(key, saved[key], imperial)}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
disabled={readOnly}
|
||||
testId={`limit-${key}`}
|
||||
onDraft={v => setPreview({ key, value: v })}
|
||||
onChange={v => onSave?.(setting, storeSpec(key, v, imperial))}
|
||||
@@ -636,7 +654,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
icon={Link2}
|
||||
label={t('roadtrip.line.connect')}
|
||||
on={!!settings.roadtrip_connect_days}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
onToggle={() => onSave?.('roadtrip_connect_days', !settings.roadtrip_connect_days)}
|
||||
/>}
|
||||
{/* Which matters most once the line IS continuous: end to end it is one
|
||||
@@ -645,12 +663,12 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
icon={Palette}
|
||||
label={t('roadtrip.line.dayColors')}
|
||||
on={!!settings.roadtrip_day_colors}
|
||||
disabled={false}
|
||||
disabled={readOnly}
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
|
||||
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
|
||||
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={readOnly}
|
||||
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -46,6 +46,10 @@ const KEY_SHAPE = /^[a-z0-9_-]+(\.[a-z0-9_-]+)+$/i;
|
||||
*
|
||||
* Deliberately generous: one resolved key is enough. Half a translation is still readable,
|
||||
* and a notice hidden by mistake is a worse failure than an ugly one.
|
||||
*
|
||||
* A bundle older than this filter cannot run it, so the server holds the release notice
|
||||
* back until the store announces the layout (see systemNoticeStore.fetch). This filter is
|
||||
* for the bundles after that one: they announce the layout but may still lack the copy.
|
||||
*/
|
||||
function hasCopy(notice: SystemNoticeDTO, t: (key: string) => string): boolean {
|
||||
const keys = notice.release
|
||||
|
||||
@@ -81,7 +81,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
@@ -514,7 +514,7 @@ export function RtSpillRow({ fromDayNumber, departs, chrome, children }: {
|
||||
</span>
|
||||
{departs && (
|
||||
<span className="ms-auto text-[0.71875rem] font-semibold tabular-nums text-m-ink">
|
||||
{t('roadtrip.spill.departs', { time: departs })}
|
||||
{t('roadtrip.spill.departs', { time: formatClockTime(departs, chrome.is12h) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,14 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
|
||||
const stage = rt.stage
|
||||
|
||||
// The map's whole-trip view leaves no day selected, and it stays that way when the
|
||||
// list comes up: the switch here restores nothing on purpose, and the plan tab's
|
||||
// toggle does that job only for its own timeline. The chain is one stage wide, so
|
||||
// with routed days and no day picked it asks for one, rather than claiming there is
|
||||
// no route while the header above prints the whole drive's distance. A day that is
|
||||
// picked and has no drive of its own still gets the empty stage further down.
|
||||
const noDayPicked = !stage && !rt.empty && !rt.loading && planner.selectedDayId == null
|
||||
|
||||
// The disc is only a control for somebody who may change a place. Read here rather than
|
||||
// inside the row so the whole chain asks once, the way `rt` and `chrome` are handled.
|
||||
const canEditPlaces = planner.can('place_edit', planner.trip)
|
||||
@@ -127,7 +135,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
searchBar ? 'pt-[calc(var(--m-safe-top,12px)+150px)]' : 'pt-[calc(var(--m-safe-top,12px)+102px)]'
|
||||
}`}
|
||||
>
|
||||
{rt.empty || !stage ? (
|
||||
{noDayPicked ? (
|
||||
<PickDay planner={planner} />
|
||||
) : rt.empty || !stage ? (
|
||||
<EmptyStage planner={planner} loading={rt.loading} />
|
||||
) : (
|
||||
<>
|
||||
@@ -375,6 +385,25 @@ function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* No day on screen, because the map is showing all of them.
|
||||
*
|
||||
* Same shape as the empty stage, different sentence: the drive is there, the chips
|
||||
* above are where a stage comes back from, and the line says so.
|
||||
*/
|
||||
function PickDay({ planner }: { planner: MTripTabPanelProps['planner'] }) {
|
||||
const { t } = planner
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-5 py-16 text-center">
|
||||
<MDancingTrek scene="guide" className="mb-2" />
|
||||
<p className="text-[0.9375rem] font-semibold text-m-ink">{t('mobileTrip.rtNoDay')}</p>
|
||||
<p className="mt-1.5 max-w-[27ch] font-geist text-[0.78125rem] leading-[1.5] text-m-muted">
|
||||
{t('mobileTrip.rtNoDayHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── small helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
function stageDateLabel(planner: MTripTabPanelProps['planner'], dayId: number): string | null {
|
||||
|
||||
@@ -179,12 +179,20 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
|
||||
{/* Delete-place confirm behind handleDeletePlace (the place edit sheet
|
||||
arms the same flag for its own two-tap delete — skip it there). */}
|
||||
arms the same flag for its own two-tap delete — skip it there).
|
||||
A night booked at the place goes down with it, and with the night
|
||||
the booking and its expense: the planner adds that as a second
|
||||
sentence, the same one the desktop question carries. */}
|
||||
<MConfirmSheet
|
||||
open={planner.deletePlaceId != null && !planner.showPlaceForm}
|
||||
onClose={() => planner.setDeletePlaceId(null)}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlace')}
|
||||
message={planner.deletePlaceNote ? (
|
||||
<>
|
||||
<span className="block">{t('trip.confirm.deletePlace')}</span>
|
||||
<span className="mt-1 block">{planner.deletePlaceNote}</span>
|
||||
</>
|
||||
) : t('trip.confirm.deletePlace')}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, ChevronUp, ImagePlus, Loader2, Reply, Trash2 } from 'lucide-react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import { useChatImages } from '../../../../components/Collab/useChatImages'
|
||||
import { useChatImages, MAX_CHAT_IMAGES } from '../../../../components/Collab/useChatImages'
|
||||
import { collabApi } from '../../../../api/client'
|
||||
import { addListener, removeListener } from '../../../../api/websocket'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
@@ -160,7 +160,10 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
|
||||
}
|
||||
|
||||
const addImageFiles = (incoming: FileList | File[]) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
images.add(incoming, ({ rejected, overflow }) => {
|
||||
if (rejected) toast.error(t('collab.chat.imageRejected'))
|
||||
if (overflow) toast.error(t('collab.chat.imageLimit', { max: MAX_CHAT_IMAGES }))
|
||||
})
|
||||
}
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-018
|
||||
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-024
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
|
||||
@@ -135,8 +135,18 @@ beforeEach(() => {
|
||||
vi.spyOn(docsyncApi, 'saveConnection').mockResolvedValue({})
|
||||
vi.spyOn(docsyncApi, 'createScope').mockResolvedValue(scope({}))
|
||||
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
|
||||
vi.spyOn(docsyncApi, 'items').mockResolvedValue([])
|
||||
vi.spyOn(docsyncApi, 'resolve').mockResolvedValue({ success: true })
|
||||
})
|
||||
|
||||
/** The status route with a number of conflicts on it, holdings as usual. */
|
||||
function withConflicts(count: number) {
|
||||
vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({
|
||||
items: { conflict: count },
|
||||
links: links.map(l => ({ id: l.id, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })),
|
||||
}))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -273,6 +283,21 @@ describe('MDocSyncSheet: moving between the views', () => {
|
||||
// A folder name is suggested from the trip so nobody has to invent one.
|
||||
expect(screen.getByDisplayValue('rome-3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-019: a folder listing that fails outright says so instead of spinning', async () => {
|
||||
// Not a provider failure, which the route folds into a 200: the request itself
|
||||
// dies, as it does when the phone loses its signal or a proxy answers 502.
|
||||
vi.spyOn(docsyncApi, 'listScopes').mockRejectedValue({ response: { status: 502, data: '<html>Bad Gateway</html>' } })
|
||||
renderSheet()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
|
||||
|
||||
expect(await screen.findByText('Something went wrong.')).toBeInTheDocument()
|
||||
expect(document.querySelector('.animate-spin')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Photos/ })).not.toBeInTheDocument()
|
||||
// The new-folder lane stays usable: a listing that failed is not a store that is gone.
|
||||
expect(screen.getByRole('button', { name: /Create/ })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MDocSyncSheet: the two lanes', () => {
|
||||
@@ -352,11 +377,92 @@ describe('MDocSyncSheet: what the owner may do', () => {
|
||||
})
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
expect(screen.queryByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).not.toBeInTheDocument()
|
||||
const paused = 'Paused: an administrator has switched this provider off. Syncing resumes once it is back on.'
|
||||
expect(screen.queryByText(paused)).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
|
||||
|
||||
expect(await screen.findByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
|
||||
// The standing notice on the binding, and the refused run's own report
|
||||
// under it, which says the same reason for this one.
|
||||
expect(await screen.findAllByText(paused)).toHaveLength(2)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(paused)
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-021: a choice the server refuses is said in the sheet, and the row stays', async () => {
|
||||
withConflicts(1)
|
||||
vi.spyOn(docsyncApi, 'items').mockResolvedValue([{ id: 40, file_name: 'boarding-pass.pdf', remote_name: 'boarding-pass.pdf' }])
|
||||
// Settled by the owner on another device a moment ago.
|
||||
vi.spyOn(docsyncApi, 'resolve').mockRejectedValue({ response: { status: 400, data: { error: 'conflict' } } })
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Resolve/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Keep the TREK version' }))
|
||||
|
||||
await waitFor(() => expect(docsyncApi.resolve).toHaveBeenCalledWith(TRIP_ID, 40, 'trek'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('The document changed on both sides.')
|
||||
// The list is read back rather than trimmed by hand, so the row is still
|
||||
// there, ready for another try.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Keep the TREK version' })).toBeEnabled())
|
||||
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument()
|
||||
expect(docsyncApi.items).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-022: a refused credential offers Reconnect, and saving runs the binding and comes back to it', async () => {
|
||||
// The list only ever opens the form for a store with no connection yet,
|
||||
// and a bound store is not listed there, so a rotated token could only
|
||||
// be entered through the API.
|
||||
links = [link({ lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })]
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }))
|
||||
|
||||
// The credential form for this store, not the folder picker.
|
||||
expect(await screen.findByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://paperless.example')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Or use one you already have')).not.toBeInTheDocument()
|
||||
expect(docsyncApi.listScopes).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/API token/), { target: { value: 'fresh-token' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(docsyncApi.saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({
|
||||
providerId: 'paperless',
|
||||
credentials: expect.objectContaining({ api_token: 'fresh-token' }),
|
||||
})))
|
||||
await waitFor(() => expect(docsyncApi.syncNow).toHaveBeenCalledWith(TRIP_ID, 11, false))
|
||||
// Back on the binding, not at the list and not in the folder picker.
|
||||
expect(await screen.findByRole('button', { name: 'Sync now' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Or use one you already have')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-023: the back arrow from the reconnect form returns to the binding, and a paused provider offers no Reconnect', async () => {
|
||||
links = [link({ lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })]
|
||||
renderSheet()
|
||||
await openDetail()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }))
|
||||
await screen.findByRole('button', { name: 'Save' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Sync now' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Add another')).not.toBeInTheDocument()
|
||||
expect(docsyncApi.saveConnection).not.toHaveBeenCalled()
|
||||
expect(docsyncApi.syncNow).not.toHaveBeenCalled()
|
||||
|
||||
// Switched off by an admin meanwhile: a new credential would change
|
||||
// nothing until it is back on, so the paused notice stands alone.
|
||||
vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({
|
||||
items: {},
|
||||
links: links.map(l => ({ id: l.id, providerOff: true, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })),
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
|
||||
|
||||
expect(await screen.findByText(/Paused: an administrator/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-014: Sync now runs that binding', async () => {
|
||||
@@ -387,6 +493,30 @@ describe('MDocSyncSheet: what a member may do', () => {
|
||||
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-020: a member is shown the conflict count but offered no choice', async () => {
|
||||
// The server refuses a member's choice with 403, so the three buttons
|
||||
// would only ever fail for them. The count is what they are owed.
|
||||
withConflicts(3)
|
||||
renderSheet({ canManage: false })
|
||||
await openDetail()
|
||||
|
||||
expect(screen.getByText('Both copies changed')).toBeInTheDocument()
|
||||
expect(screen.getByText('Changed in both places. Pick which one to keep.')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Resolve/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Keep the TREK version' })).not.toBeInTheDocument()
|
||||
expect(docsyncApi.items).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-024: a member whose store refused its credential is told, but not offered Reconnect', async () => {
|
||||
links = [link({ lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' })]
|
||||
renderSheet({ canManage: false })
|
||||
await openDetail()
|
||||
|
||||
expect(screen.getByText(/The credentials were refused/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DOCSYNC-MOBILE-016: a member is not offered stores to add, and an unbound trip explains who sets it up', async () => {
|
||||
links = []
|
||||
renderSheet({ canManage: false })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, Link2Off,
|
||||
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, KeyRound, Link2Off,
|
||||
Loader2, Plus, RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
} from '../../../../components/Files/docsync/useDocSync'
|
||||
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
|
||||
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
|
||||
@@ -52,10 +52,18 @@ export default function MDocSyncSheet({
|
||||
const [selected, setSelected] = useState<number | null>(null)
|
||||
const [pending, setPending] = useState<DocSyncProvider | null>(null)
|
||||
const [confirmUnlink, setConfirmUnlink] = useState(false)
|
||||
// The binding whose credentials are being entered again, while the connect
|
||||
// view is open for it. Kept apart from `pending`, whose next step is the
|
||||
// folder picker: here the folder is known and the next step is the run that
|
||||
// the refusal stopped.
|
||||
const [reconnectFor, setReconnectFor] = useState<number | null>(null)
|
||||
|
||||
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
|
||||
const available = sync.providers.filter(p => !bound.has(p.id))
|
||||
const link = sync.links.find(l => l.id === selected) ?? null
|
||||
// Undefined once an admin has switched the store off: the form needs the
|
||||
// provider's field list, and the providers route no longer carries it.
|
||||
const linkProvider = link ? sync.providers.find(p => p.id === link.providerId) : undefined
|
||||
|
||||
// A store that was removed elsewhere must not leave the detail view pointing
|
||||
// at nothing.
|
||||
@@ -66,14 +74,37 @@ export default function MDocSyncSheet({
|
||||
// Every open starts at the list: a sheet that reopens three steps deep is a
|
||||
// sheet somebody has to find their way out of.
|
||||
useEffect(() => {
|
||||
if (!open) { setView('list'); setPending(null) }
|
||||
if (!open) { setView('list'); setPending(null); setReconnectFor(null) }
|
||||
}, [open])
|
||||
|
||||
const openStore = (p: DocSyncProvider) => {
|
||||
setReconnectFor(null)
|
||||
setPending(p)
|
||||
setView(sync.connectionFor(p.id) ? 'scope' : 'connect')
|
||||
}
|
||||
|
||||
const reconnect = (l: DocSyncLink, p: DocSyncProvider) => {
|
||||
setReconnectFor(l.id)
|
||||
setPending(p)
|
||||
setView('connect')
|
||||
}
|
||||
|
||||
/** The step before this one: the binding for a reconnect, the list otherwise. */
|
||||
const back = () => {
|
||||
if (reconnectFor !== null) { setReconnectFor(null); setView('detail') } else setView('list')
|
||||
}
|
||||
|
||||
const connected = () => {
|
||||
if (reconnectFor === null) { setView('scope'); return }
|
||||
// Run straight away, as binding does: the card still reports the refusal
|
||||
// until a run says otherwise, and the person has just done the one thing
|
||||
// that could change the answer.
|
||||
const id = reconnectFor
|
||||
setReconnectFor(null)
|
||||
setView('detail')
|
||||
void sync.syncNow(id)
|
||||
}
|
||||
|
||||
const title =
|
||||
view === 'connect' || view === 'scope'
|
||||
? pending?.name ?? t('docsync.title')
|
||||
@@ -87,7 +118,7 @@ export default function MDocSyncSheet({
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{view !== 'list' && (
|
||||
<MIconBtn variant="neutral" size={34} onClick={() => setView('list')} ariaLabel={t('common.back')}>
|
||||
<MIconBtn variant="neutral" size={34} onClick={back} ariaLabel={t('common.back')}>
|
||||
<ChevronLeft size={16} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
)}
|
||||
@@ -115,7 +146,7 @@ export default function MDocSyncSheet({
|
||||
<ConnectView
|
||||
provider={pending}
|
||||
sync={sync}
|
||||
onDone={() => setView('scope')}
|
||||
onDone={connected}
|
||||
/>
|
||||
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
|
||||
<ScopeView
|
||||
@@ -132,6 +163,7 @@ export default function MDocSyncSheet({
|
||||
sync={sync}
|
||||
canManage={canManage}
|
||||
onUnlink={() => setConfirmUnlink(true)}
|
||||
onReconnect={linkProvider ? () => reconnect(link, linkProvider) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
@@ -233,6 +265,7 @@ function DetailView({
|
||||
sync,
|
||||
canManage,
|
||||
onUnlink,
|
||||
onReconnect,
|
||||
}: {
|
||||
link: DocSyncLink
|
||||
tripId: number | string
|
||||
@@ -240,9 +273,19 @@ function DetailView({
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
canManage: boolean
|
||||
onUnlink: () => void
|
||||
/**
|
||||
* Opens the credential form for this binding's store. Only the sheet can,
|
||||
* because the form needs the provider's field list, which the card does
|
||||
* not carry; left out, a refused credential is reported but not curable.
|
||||
*/
|
||||
onReconnect?: () => void
|
||||
}) {
|
||||
const { t, language } = useTranslation()
|
||||
const busy = sync.busy === `sync-${link.id}`
|
||||
// Not while the provider is switched off: the paused notice stands in front
|
||||
// of the refusal then, and a new credential would change nothing until an
|
||||
// admin turns the provider back on.
|
||||
const reconnect = canManage && onReconnect && !link.providerOff && needsReauth(link) ? onReconnect : null
|
||||
const holdings = link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }
|
||||
const pushOn = link.direction === 'both' || link.direction === 'push'
|
||||
const pullOn = link.direction === 'both' || link.direction === 'pull'
|
||||
@@ -305,7 +348,30 @@ function DetailView({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MConflicts tripId={tripId} sync={sync} count={sync.itemCounts.conflict ?? 0} />
|
||||
{/* The one state a person can act on from here. Without this the only
|
||||
way past a rotated token was the API: the list opens the form for a
|
||||
store that has no connection yet, and this one has. */}
|
||||
{reconnect && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-[0.8125rem] font-bold text-m-ink"
|
||||
>
|
||||
<KeyRound size={15} strokeWidth={2.2} />
|
||||
{t('docsync.binding.reconnect')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Every action on this view reports here: a refused run, a switch that
|
||||
flipped back, a conflict the server would not settle. Nothing said
|
||||
what happened to a tap before. */}
|
||||
{sync.error && (
|
||||
<p role="alert" className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
|
||||
{t(`docsync.error.${sync.error}`)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MConflicts tripId={tripId} sync={sync} count={sync.itemCounts.conflict ?? 0} canManage={canManage} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -767,33 +833,48 @@ function MConflicts({
|
||||
tripId,
|
||||
sync,
|
||||
count,
|
||||
canManage,
|
||||
}: {
|
||||
tripId: number | string
|
||||
sync: ReturnType<typeof useDocSync>
|
||||
count: number
|
||||
canManage: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const conflicts = useConflicts(tripId, sync, open)
|
||||
const conflicts = useConflicts(tripId, sync, canManage && open)
|
||||
if (count === 0) return null
|
||||
|
||||
const heading = (
|
||||
<span className="min-w-0">
|
||||
<span className="block font-geist text-[0.75rem] font-bold text-m-ink">{t('docsync.conflict.title')}</span>
|
||||
<span className="mt-0.5 block font-geist text-[0.6875rem] text-m-muted">{t('docsync.issues.conflict')}</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-geist text-[0.75rem] font-bold text-m-ink">{t('docsync.conflict.title')}</span>
|
||||
<span className="mt-0.5 block font-geist text-[0.6875rem] text-m-muted">{t('docsync.issues.conflict')}</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-geist text-[0.6875rem] font-bold text-m-ink">
|
||||
{open ? t('common.close') : t('docsync.conflict.resolve', { count })}
|
||||
</span>
|
||||
</button>
|
||||
{/* Only the owner gets to open it: the server refuses a member's choice,
|
||||
so a member gets the count, as the desktop panel gives it. */}
|
||||
{canManage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex w-full items-center justify-between gap-3 text-left"
|
||||
>
|
||||
{heading}
|
||||
<span className="shrink-0 font-geist text-[0.6875rem] font-bold text-m-ink">
|
||||
{open ? t('common.close') : t('docsync.conflict.resolve', { count })}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-between gap-3 text-left">
|
||||
{heading}
|
||||
<span className="shrink-0 font-geist text-[0.6875rem] font-bold tabular-nums text-m-ink">{count}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
{canManage && open && (
|
||||
<ul className="mt-2.5 space-y-2">
|
||||
{conflicts.items === null && (
|
||||
<li className="flex justify-center py-2">
|
||||
|
||||
@@ -294,7 +294,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
reservationPrefill, transportPrefill, importReviewActive, advanceImportReview,
|
||||
routeShown, setRouteShown, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
||||
stayRelease, setStayRelease, confirmStayRelease,
|
||||
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
@@ -1137,14 +1138,26 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
onConfirm={confirmDeletePlace}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlace')}
|
||||
message={deletePlaceNote ? `${t('trip.confirm.deletePlace')} ${deletePlaceNote}` : t('trip.confirm.deletePlace')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceIds?.length}
|
||||
onClose={() => setDeletePlaceIds(null)}
|
||||
onConfirm={confirmDeletePlaces}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
|
||||
message={deletePlacesNote
|
||||
? `${t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} ${deletePlacesNote}`
|
||||
: t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!stayRelease}
|
||||
onClose={() => setStayRelease(null)}
|
||||
onConfirm={confirmStayRelease}
|
||||
title={t('roadtrip.stay.releaseTitle')}
|
||||
message={stayRelease?.booking
|
||||
? t('roadtrip.stay.releaseBookedBody', { name: stayRelease.name, booking: stayRelease.booking })
|
||||
: t('roadtrip.stay.releaseBody', { name: stayRelease?.name ?? '' })}
|
||||
confirmLabel={t('roadtrip.stay.releaseAction')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PAGE-TPW-001 to FE-PAGE-TPW-061
|
||||
// FE-PAGE-TPW-001 to FE-PAGE-TPW-062
|
||||
//
|
||||
// The planner page is a wiring container: everything stateful lives in
|
||||
// useTripPlanner (covered in src/pages/tripPlanner/useTripPlanner.test.tsx).
|
||||
@@ -285,6 +285,9 @@ function baseState(): HookState {
|
||||
setDeletePlaceId: vi.fn(),
|
||||
deletePlaceIds: null,
|
||||
setDeletePlaceIds: vi.fn(),
|
||||
stayRelease: null,
|
||||
setStayRelease: vi.fn(),
|
||||
confirmStayRelease: vi.fn(async () => undefined),
|
||||
visibleConnections: [],
|
||||
toggleConnection: vi.fn(),
|
||||
allConnectionsShown: false,
|
||||
@@ -1291,4 +1294,17 @@ describe('TripPlannerPage — modals', () => {
|
||||
expect(hookState.setDeletePlaceIds).toHaveBeenCalledWith(null)
|
||||
expect(hookState.confirmDeletePlaces).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-062: the question before a booked night becomes a pause answers through the hook', () => {
|
||||
renderPage({ stayRelease: { stop: { stopType: null, dwellMinutes: 30 }, name: 'Hotel Fjord', booking: 'Booking 4711' } })
|
||||
|
||||
const [, , release] = confirmDialogs as unknown as Array<Record<string, () => unknown> & { isOpen: boolean }>
|
||||
expect(release.isOpen).toBe(true)
|
||||
|
||||
act(() => { release.onClose() })
|
||||
expect(hookState.setStayRelease).toHaveBeenCalledWith(null)
|
||||
|
||||
act(() => { release.onConfirm() })
|
||||
expect(hookState.confirmStayRelease).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMSET-001 to FE-ADMSET-040
|
||||
// FE-ADMSET-001 to FE-ADMSET-042
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -566,4 +566,26 @@ describe('AdminSettingsTab', () => {
|
||||
expect(screen.queryByRole('button', { name: /trek place index/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/place search uses openstreetmap/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-042: a failing transit provider save rolls back and says why', async () => {
|
||||
// CustomSelect renders a plain button named after the selected option, so the
|
||||
// trigger is found through its own block, and the menu entry is the button that
|
||||
// is not the trigger.
|
||||
server.use(http.put('/api/admin/transit-provider', () => HttpResponse.json({ error: 'Settings are read-only' }, { status: 500 })));
|
||||
const admin = renderTab({
|
||||
transitProvider: 'transitous',
|
||||
setTransitProviderState: vi.fn(),
|
||||
transitGoogleKeySource: 'instance',
|
||||
setTransitGoogleKeySource: vi.fn(),
|
||||
});
|
||||
const block = screen.getByText('Transit Provider').closest<HTMLElement>('.rounded-xl')!;
|
||||
|
||||
fireEvent.click(within(block).getByRole('button'));
|
||||
fireEvent.click(screen.getByText('Google'));
|
||||
|
||||
expect(admin.setTransitProviderState).toHaveBeenNthCalledWith(1, 'google');
|
||||
await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Settings are read-only'));
|
||||
expect(admin.setTransitProviderState).toHaveBeenLastCalledWith('transitous');
|
||||
expect(admin.setTransitGoogleKeySource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -597,7 +597,12 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
try {
|
||||
const saved = await adminApi.updateTransitProvider(next)
|
||||
setTransitGoogleKeySource(saved.googleKeySource)
|
||||
} catch { setTransitProviderState(previous) }
|
||||
} catch (err: unknown) {
|
||||
// The select springs back on its own; without a word that reads
|
||||
// as a broken control, not as a save that failed.
|
||||
setTransitProviderState(previous)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'transitous', label: t('admin.transitProvider.transitous') },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-099
|
||||
// FE-TP-ROAD-001 to FE-TP-ROAD-108
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
@@ -11,7 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useBackgroundTasksStore } from '../../store/backgroundTasksStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment } from '../../../tests/helpers/factories'
|
||||
import { buildUser, buildTrip, buildDay, buildPlace, buildAssignment, buildReservation } from '../../../tests/helpers/factories'
|
||||
import {
|
||||
addonsApi, accommodationsApi, authApi, tripsApi, healthApi, airtrailApi, mapsApi,
|
||||
} from '../../api/client'
|
||||
@@ -2168,3 +2168,257 @@ describe('useTripPlanner road trip: the phone feed', () => {
|
||||
expect(result.current.roadtripFeedActive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The vias of a day are anchored by POSITION among the stops the router counts, and
|
||||
* that count is taken over the STORED list: the day list read as a plan hides the stop
|
||||
* a lodging booking put on the day and, behind the switch, the service stops, but the
|
||||
* server and the routing round count every one of them. The handlers below are
|
||||
* reachable from the place inspector and the day plan in Days mode, where the hook's
|
||||
* own list is the shorter one, and a plan measured on it deleted the via behind a
|
||||
* hidden hotel or pinned it to a leg nobody drew.
|
||||
*/
|
||||
describe('useTripPlanner road trip: corrections measured on the stored day', () => {
|
||||
/** A day whose last stop is the hotel a booking put there, hidden under Days. */
|
||||
const dayWithHiddenNight = () => {
|
||||
seedTrip({
|
||||
days: [buildDay({ id: 5, day_number: 1 })],
|
||||
assignments: { '5': [stopAt(11, 5, 0), stopAt(13, 5, 1), stopAt(14, 5, 2, { accommodation_id: 7 })] },
|
||||
})
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-100: removing a stop under Days counts the night the list hides, so the via behind it moves instead of going', async () => {
|
||||
dayWithHiddenNight()
|
||||
// On the leg from the second stop to the hotel.
|
||||
rt.vias.byDay = { 5: [via(1, 5, 1)] }
|
||||
const { result } = await renderRoadtrip()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
expect(result.current.assignments['5'].map(a => a.id)).toEqual([11, 13])
|
||||
|
||||
await act(async () => { await result.current.handleRemoveAssignment(5, 11) })
|
||||
|
||||
expect(actions.removeAssignment).toHaveBeenCalledWith(42, 5, 11)
|
||||
// Three stops, not two: with two the day would have no leg left and every via
|
||||
// of the day would be deleted on the server.
|
||||
expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 1, after_order_index: 0 }], remove: [] })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-101: a drag under Days keeps a via behind the stop it was drawn after, hotel included', async () => {
|
||||
dayWithHiddenNight()
|
||||
// On the leg from the first stop to the second.
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0)] }
|
||||
const { result } = await renderRoadtrip()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
|
||||
await act(async () => result.current.handleReorder(5, [13, 11]))
|
||||
|
||||
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 11, 14])
|
||||
// The first stop is now second, and the leg from it to the hotel still exists.
|
||||
await waitFor(() => expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 1, after_order_index: 1 }], remove: [] }))
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-102: a drop under Days past the last visible row still lands before the hidden hotel', async () => {
|
||||
dayWithHiddenNight()
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 53, lng: 10 }, { lat: 54, lng: 11 }, { lat: 55, lng: 12 }],
|
||||
geometry: [[53, 10], [54, 11], [55, 12]],
|
||||
}]
|
||||
// On the leg from the second stop to the hotel, past where the new stop goes.
|
||||
rt.vias.byDay = { 5: [via(1, 5, 1, 0, 54.8, 11.8)] }
|
||||
seedStore(useTripStore, { places: [buildPlace({ id: 300, lat: 54.2, lng: 11.2 })] })
|
||||
const { result } = await renderRoadtrip()
|
||||
act(() => result.current.toggleRoadtripMode())
|
||||
|
||||
// Row index 2 is the hotel's row in the day as it is stored, one past the last
|
||||
// row the day plan shows.
|
||||
await act(async () => { await result.current.handleAssignToDay(300, 5, 2) })
|
||||
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 300, 2)
|
||||
expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 1, after_order_index: 2 }], remove: [] })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-103: a row index is translated into a stop index before the vias are corrected', async () => {
|
||||
// A row without coordinates is never a stop, so the number of rows ahead of a drop
|
||||
// is not the number of stops ahead of it.
|
||||
seedTrip({
|
||||
days: [buildDay({ id: 5, day_number: 1 })],
|
||||
assignments: { '5': [placeless(10, 5, 0), stopAt(11, 5, 1), stopAt(13, 5, 2)] },
|
||||
})
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 54, lng: 11 }, { lat: 55, lng: 12 }],
|
||||
geometry: [[54, 11], [55, 12]],
|
||||
}]
|
||||
rt.vias.byDay = { 5: [via(1, 5, 0, 0, 54.8, 11.8)] }
|
||||
seedStore(useTripStore, { places: [buildPlace({ id: 300, lat: 54.2, lng: 11.2 })] })
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
await act(async () => { await result.current.handleAssignToDay(300, 5, 2) })
|
||||
|
||||
// Row 2 is stop 1: the via on the only leg is now behind the new stop.
|
||||
expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 1, after_order_index: 1 }], remove: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a booked night in the popup', () => {
|
||||
const hotel = () => buildPlace({ id: 102, name: 'Hotel Fjord', stop_type: 'hotel', lat: 60.39, lng: 5.32 })
|
||||
|
||||
/** The popup as `openPlaceEditor` opens it on a booked night. */
|
||||
const editingNight = (result: { current: ReturnType<typeof useTripPlanner> }) => {
|
||||
act(() => {
|
||||
result.current.setStopDraft({
|
||||
poi: poi({ name: 'Hotel Fjord', category: 'hotel', lat: 60.39, lng: 5.32 }) as never,
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
position: 1,
|
||||
editing: { placeId: 102, stopType: 'hotel', dwellMinutes: 30, accommodationId: 7, checkIn: '15:00', checkOut: '' },
|
||||
overnight: { days: [{ id: 5, number: 1, date: null }, { id: 6, number: 2, date: null }], defaultEndDayId: 6 },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-TP-ROAD-104: switching a booked night to a pause asks first, naming the booking that would go', async () => {
|
||||
const stay = hotel()
|
||||
seedTrip({
|
||||
places: [stay],
|
||||
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })],
|
||||
assignments: { '5': [buildAssignment({ id: 12, day_id: 5, order_index: 1, place: stay, accommodation_id: 7 })] },
|
||||
reservations: [buildReservation({ id: 9, type: 'hotel', title: 'Booking 4711', accommodation_id: 7 })],
|
||||
})
|
||||
const remove = vi.spyOn(accommodationsApi, 'delete').mockResolvedValue({} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
editingNight(result)
|
||||
|
||||
await act(async () => { await result.current.saveStopDraft({ stopType: null, dwellMinutes: 45 }) })
|
||||
|
||||
// Nothing has been written, and the popup is still open behind the question.
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(actions.updatePlace).not.toHaveBeenCalled()
|
||||
expect(result.current.stopDraft).not.toBeNull()
|
||||
expect(result.current.stayRelease).toEqual({
|
||||
stop: { stopType: null, dwellMinutes: 45 },
|
||||
name: 'Hotel Fjord',
|
||||
booking: 'Booking 4711',
|
||||
})
|
||||
|
||||
// A no leaves the traveller where they were.
|
||||
act(() => { result.current.setStayRelease(null) })
|
||||
expect(result.current.stopDraft).not.toBeNull()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
|
||||
// A yes is the very save that was asked for.
|
||||
await act(async () => { await result.current.saveStopDraft({ stopType: null, dwellMinutes: 45 }) })
|
||||
await act(async () => { await result.current.confirmStayRelease() })
|
||||
|
||||
expect(actions.updatePlace).toHaveBeenCalledWith(42, 102, { stop_type: null, duration_minutes: 45 })
|
||||
expect(remove).toHaveBeenCalledWith(42, 7, { keepStop: true })
|
||||
expect(result.current.stayRelease).toBeNull()
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-105: a night without a booking of its own asks too, and names none', async () => {
|
||||
const stay = hotel()
|
||||
seedTrip({
|
||||
places: [stay],
|
||||
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })],
|
||||
assignments: { '5': [buildAssignment({ id: 12, day_id: 5, order_index: 1, place: stay, accommodation_id: 7 })] },
|
||||
})
|
||||
vi.mocked(accommodationRepo.list).mockResolvedValue({
|
||||
accommodations: [{ id: 7, trip_id: 42, place_id: 102, start_day_id: 5, end_day_id: 6, reservation_title: null }],
|
||||
} as never)
|
||||
const remove = vi.spyOn(accommodationsApi, 'delete').mockResolvedValue({} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
await waitFor(() => expect(result.current.tripAccommodations).toHaveLength(1))
|
||||
editingNight(result)
|
||||
|
||||
await act(async () => { await result.current.saveStopDraft({ stopType: null, dwellMinutes: 20 }) })
|
||||
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(result.current.stayRelease).toMatchObject({ name: 'Hotel Fjord', booking: null })
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-106: a check-in moved in the popup folds the re-seated night into the day', async () => {
|
||||
// The server seats the night by its check-in and answers with the moved stop. The
|
||||
// socket skips the session that asked, so this answer is the only way the writer's
|
||||
// own rail learns the new order before a reload.
|
||||
const stay = hotel()
|
||||
const museum = buildPlace({ id: 101, name: 'Museum', lat: 60.4, lng: 5.3 })
|
||||
const dinner = buildPlace({ id: 103, name: 'Dinner', lat: 60.41, lng: 5.31 })
|
||||
const night = buildAssignment({ id: 12, day_id: 5, order_index: 1, place: stay, accommodation_id: 7 })
|
||||
seedTrip({
|
||||
places: [museum, stay, dinner],
|
||||
days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })],
|
||||
assignments: { '5': [
|
||||
buildAssignment({ id: 11, day_id: 5, order_index: 0, place: museum }),
|
||||
night,
|
||||
buildAssignment({ id: 13, day_id: 5, order_index: 2, place: dinner }),
|
||||
] },
|
||||
})
|
||||
const update = vi.spyOn(accommodationsApi, 'update').mockResolvedValue({
|
||||
accommodation: { id: 7 },
|
||||
movedAssignment: { assignment: { ...night, order_index: 2 }, oldDayId: 5 },
|
||||
} as never)
|
||||
const { result } = await renderRoadtrip()
|
||||
editingNight(result)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveStopDraftAsNight({ endDayId: 6, checkIn: '20:00', checkOut: '' })
|
||||
})
|
||||
|
||||
expect(update).toHaveBeenCalledWith(42, 7, { place_id: 102, start_day_id: 5, end_day_id: 6, check_in: '20:00', check_out: null })
|
||||
expect(useTripStore.getState().assignments['5'].find(a => a.id === 12)?.order_index).toBe(2)
|
||||
// The neighbours were renumbered as well, and the answer names only the night.
|
||||
expect(actions.refreshDays).toHaveBeenCalledWith(42)
|
||||
expect(result.current.stopDraft).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripPlanner road trip: a hit handed to the full form', () => {
|
||||
it('FE-TP-ROAD-107: saving it through the form re-anchors the vias the way the popup does', async () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.corridor.insertIndexFor.mockReturnValue(1)
|
||||
rt.routes.days = [{
|
||||
dayId: 5,
|
||||
dayNumber: 1,
|
||||
stops: [{ lat: 53.55, lng: 9.99 }, { lat: 53.0, lng: 11.5 }, { lat: 52.52, lng: 13.4 }],
|
||||
geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]],
|
||||
}]
|
||||
// On the second leg, behind the stop the hit goes in front of.
|
||||
rt.vias.byDay = { 5: [via(11, 5, 1, 0, 52.8, 12.2)] }
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.handlePoiClick(poi({ lat: 53.2, lng: 10.7 }) as never) })
|
||||
act(() => { result.current.stopDraftToForm({ stopType: 'fuel', dwellMinutes: 10 }) })
|
||||
await act(async () => { await result.current.handleSavePlace({ name: 'Rasthof', lat: 53.2, lng: 10.7 }) })
|
||||
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 900, 1)
|
||||
// Left alone the via keeps index 1 and is redrawn onto the leg the new stop took.
|
||||
expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 11, after_order_index: 2 }], remove: [] })
|
||||
const assigned = actions.assignPlaceToDay.mock.invocationCallOrder[0] ?? 0
|
||||
const corrected = rt.vias.reanchor.mock.invocationCallOrder[0] ?? 0
|
||||
expect(assigned).toBeLessThan(corrected)
|
||||
})
|
||||
|
||||
it('FE-TP-ROAD-108: the position dies with the form, so the next add from a day goes to its end', async () => {
|
||||
seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
|
||||
rt.corridor.day = { dayId: 5, dayNumber: 1 }
|
||||
rt.corridor.insertIndexFor.mockReturnValue(2)
|
||||
const { result } = await renderRoadtrip()
|
||||
|
||||
act(() => { result.current.handlePoiClick(poi() as never) })
|
||||
act(() => { result.current.stopDraftToForm() })
|
||||
expect(result.current.placeFormDayId).toBe(5)
|
||||
// Closed unsaved, then opened again from another day's own "add place".
|
||||
act(() => { result.current.setShowPlaceForm(false) })
|
||||
act(() => { result.current.setPlaceFormDayId(6); result.current.setShowPlaceForm(true) })
|
||||
await act(async () => { await result.current.handleSavePlace({ name: 'Museum' }) })
|
||||
|
||||
expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 6, 900, null)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-115
|
||||
// FE-TP-HOOK-001 to FE-TP-HOOK-119
|
||||
import React from 'react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n/TranslationContext'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useTripPlanner } from './useTripPlanner'
|
||||
import { useTripStore, type TripStoreState } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
@@ -755,6 +756,31 @@ describe('useTripPlanner — map derivations', () => {
|
||||
expect(result.current.dayPlaces).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-031c: a service stop on the selected day takes no number on the map', async () => {
|
||||
const hamburg = geo(1)
|
||||
const pump = geo(2, { stop_type: 'fuel' })
|
||||
const berlin = geo(3)
|
||||
seedTrip({
|
||||
places: [hamburg, pump, berlin],
|
||||
selectedDayId: 7,
|
||||
assignments: {
|
||||
'7': [
|
||||
buildAssignment({ id: 10, day_id: 7, place: hamburg, order_index: 0 }),
|
||||
buildAssignment({ id: 11, day_id: 7, place: pump, order_index: 1 }),
|
||||
buildAssignment({ id: 12, day_id: 7, place: berlin, order_index: 2 }),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
|
||||
// The pump is drawn without a badge and the rail gives it no number, so the stop
|
||||
// after it is the second, not the third. The pin and the rail have to agree.
|
||||
expect(result.current.dayOrderMap).toEqual({ 1: [1], 3: [2] })
|
||||
// Still on the map: what is skipped is the number, not the stop.
|
||||
expect(result.current.dayPlaces).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-032: without a selected day both day derivations stay empty', async () => {
|
||||
seedTrip({ places: [geo(1)] })
|
||||
|
||||
@@ -1258,6 +1284,56 @@ describe('useTripPlanner — place CRUD', () => {
|
||||
expect(toasts.some(t => t.message === 'nope')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-118: the delete question names the booked night, its booking and the expense before the yes', async () => {
|
||||
// The server takes the night down with the place, and the booking and its
|
||||
// expense with the night. The question only named the place, so a traveller
|
||||
// deleting a hotel learnt about the rest from the costs total afterwards.
|
||||
const hotel = buildPlace({ id: 1, name: 'Hotel Fjord', lat: 60.39, lng: 5.32 })
|
||||
const cafe = buildPlace({ id: 2, name: 'Cafe', lat: 1, lng: 2 })
|
||||
seedTrip({
|
||||
places: [hotel, cafe],
|
||||
reservations: [buildReservation({ id: 9, type: 'hotel', title: 'Booking 4711', accommodation_id: 7 })],
|
||||
})
|
||||
vi.mocked(accommodationRepo.list).mockResolvedValue({
|
||||
accommodations: [{ id: 7, trip_id: 42, place_id: 1, start_day_id: 5, end_day_id: 6 }] as never,
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
await waitFor(() => expect(result.current.tripAccommodations).toHaveLength(1))
|
||||
const { result: i18n } = renderHook(() => useTranslation(), { wrapper })
|
||||
|
||||
act(() => { result.current.handleDeletePlace(1) })
|
||||
expect(result.current.deletePlaceNote).toBe(
|
||||
i18n.current.t('trip.confirm.deletePlaceBooked', { name: 'Hotel Fjord', booking: 'Booking 4711' }),
|
||||
)
|
||||
// Still a question: nothing has been written.
|
||||
expect(actions.deletePlace).not.toHaveBeenCalled()
|
||||
|
||||
// A place without a night adds nothing to the question.
|
||||
act(() => { result.current.handleDeletePlace(2) })
|
||||
expect(result.current.deletePlaceNote).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-119: a bulk delete warns once any of the places carries a night, booking or not', async () => {
|
||||
const hotel = buildPlace({ id: 1, name: 'Hotel Fjord', lat: 60.39, lng: 5.32 })
|
||||
const cafe = buildPlace({ id: 2, name: 'Cafe', lat: 1, lng: 2 })
|
||||
seedTrip({ places: [hotel, cafe] })
|
||||
vi.mocked(accommodationRepo.list).mockResolvedValue({
|
||||
accommodations: [{ id: 7, trip_id: 42, place_id: 1, start_day_id: 5, end_day_id: 6 }] as never,
|
||||
})
|
||||
|
||||
const { result } = await renderPlanner()
|
||||
await waitFor(() => expect(result.current.tripAccommodations).toHaveLength(1))
|
||||
const { result: i18n } = renderHook(() => useTranslation(), { wrapper })
|
||||
|
||||
// A night without a partner booking still goes with the place, so it is still named.
|
||||
act(() => { result.current.setDeletePlaceIds([2, 1]) })
|
||||
expect(result.current.deletePlacesNote).toBe(i18n.current.t('trip.confirm.deletePlaceNight', { name: 'Hotel Fjord' }))
|
||||
|
||||
act(() => { result.current.setDeletePlaceIds([2]) })
|
||||
expect(result.current.deletePlacesNote).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TP-HOOK-061: a bulk category change restores each previous category group on undo', async () => {
|
||||
seedTrip({
|
||||
places: [
|
||||
|
||||
@@ -309,6 +309,14 @@ export function useTripPlanner() {
|
||||
* Null means the old behaviour: the server appends it at the end.
|
||||
*/
|
||||
const [placeFormPosition, setPlaceFormPosition] = useState<number | null>(null)
|
||||
// The position belongs to the form it was opened with and to nothing after it. The
|
||||
// day-scoped openers set the day, the form's close clears the coordinates, but the
|
||||
// position is written by one opener and read by every save, so a stop handed to the
|
||||
// form from the corridor popup once left the next add from any day landing at that
|
||||
// same index. Tied to the form being open, the only time it means anything.
|
||||
useEffect(() => {
|
||||
if (!showPlaceForm) setPlaceFormPosition(null)
|
||||
}, [showPlaceForm])
|
||||
/**
|
||||
* Whether the open place form is asking for a service stop on the drive.
|
||||
*
|
||||
@@ -333,6 +341,20 @@ export function useTripPlanner() {
|
||||
* instead, and the form stays one click away behind "more details".
|
||||
*/
|
||||
const [stopDraft, setStopDraft] = useState<RoadtripStopDraft | null>(null)
|
||||
/**
|
||||
* A booked night the popup was asked to turn into a pause, waiting for a yes.
|
||||
*
|
||||
* The switch in the popup reads like a change of stop kind, but the night is a
|
||||
* booking row, and the server takes the reservation and the expense written against
|
||||
* it down with that row. It is the one write the popup can make that nothing brings
|
||||
* back, so it is the one that asks first. The name and the booking title are read
|
||||
* once, here, so the dialog does not have to know where either lives.
|
||||
*/
|
||||
const [stayRelease, setStayRelease] = useState<{
|
||||
stop: { stopType: RoadtripStopType | null; dwellMinutes: number }
|
||||
name: string
|
||||
booking: string | null
|
||||
} | null>(null)
|
||||
const [reservationModalDayId, setReservationModalDayId] = useState<number | null>(null)
|
||||
|
||||
// The bottom-nav "+" opens the new-place form via ?create=place.
|
||||
@@ -475,6 +497,38 @@ export function useTripPlanner() {
|
||||
const mobilePlacesScrollTopRef = useRef<number>(0)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||
/**
|
||||
* The sentence the delete question adds when a night is booked at one of the places.
|
||||
*
|
||||
* The server takes a booked night down with its place, and with the night the
|
||||
* booking made for it and the expense written against that booking. The question
|
||||
* itself only names the place, and those are the rows the traveller least expects
|
||||
* to lose, so the dialog says so before the yes. The expense list is loaded with
|
||||
* the costs tab, not here, so the sentence speaks of any expense rather than
|
||||
* counting them. Null when nothing beyond the place is at stake.
|
||||
*/
|
||||
const bookedNightsNote = useCallback((placeIds: number[]): string | null => {
|
||||
const stays = tripAccommodations.filter(stay => stay.place_id != null && placeIds.includes(stay.place_id))
|
||||
if (stays.length === 0) return null
|
||||
const names = [...new Set(stays.map(stay => allPlaces.find(p => p.id === stay.place_id)?.name ?? stay.place_name ?? ''))]
|
||||
.filter(Boolean)
|
||||
const bookings = stays
|
||||
.map(stay => reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === stay.id)?.title
|
||||
?? stay.reservation_title ?? null)
|
||||
.filter((title): title is string => !!title)
|
||||
const name = names.join(', ')
|
||||
return bookings.length > 0
|
||||
? t('trip.confirm.deletePlaceBooked', { name, booking: bookings.join(', ') })
|
||||
: t('trip.confirm.deletePlaceNight', { name })
|
||||
}, [tripAccommodations, allPlaces, reservations, t])
|
||||
const deletePlaceNote = useMemo(
|
||||
() => (deletePlaceId ? bookedNightsNote([deletePlaceId]) : null),
|
||||
[deletePlaceId, bookedNightsNote],
|
||||
)
|
||||
const deletePlacesNote = useMemo(
|
||||
() => (deletePlaceIds?.length ? bookedNightsNote(deletePlaceIds) : null),
|
||||
[deletePlaceIds, bookedNightsNote],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip) return
|
||||
@@ -885,16 +939,38 @@ export function useTripPlanner() {
|
||||
*
|
||||
* This is the index space `after_order_index` lives in: sorted by `order_index`, and
|
||||
* filtered to the rows that have coordinates, because a place the map cannot put
|
||||
* anywhere is not a point the router is given. Built from `assignments` rather than
|
||||
* from `roadtripRoutes` so it also answers for a day with one stop or none — exactly
|
||||
* the day a stop gets pushed onto when a leg turns out too long.
|
||||
* anywhere is not a point the router is given. Built from the STORED list, the same
|
||||
* one the routing round and the server count over. The day list read as a plan
|
||||
* hides the stop a lodging booking put on the day and, behind the switch, the
|
||||
* service stops; the handlers that correct a day's vias run in that mode too, and a
|
||||
* plan measured on the shorter list deleted a via behind a hidden hotel or pinned
|
||||
* it to a leg nobody drew. Built from the list rather than from `roadtripRoutes` so
|
||||
* it also answers for a day with one stop or none, which is exactly the day a stop
|
||||
* gets pushed onto when a leg turns out too long.
|
||||
*/
|
||||
const roadtripStopsOf = useCallback((dayId: number) =>
|
||||
(assignments[String(dayId)] ?? [])
|
||||
(storedAssignments[String(dayId)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number'),
|
||||
[assignments])
|
||||
[storedAssignments])
|
||||
|
||||
/**
|
||||
* Where a place dropped at `position` in a day's row list lands among the stops the
|
||||
* road trip counts.
|
||||
*
|
||||
* The day plan hands over a row index and the store splices the new row in at that
|
||||
* index, so the stop's place in the chain is the number of routable rows ahead of
|
||||
* it, not the index itself: a row without coordinates is never a stop.
|
||||
*/
|
||||
const roadtripIndexOf = useCallback((dayId: number, position: number) =>
|
||||
(storedAssignments[String(dayId)] ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
.slice(0, Math.max(0, position))
|
||||
.filter(a => typeof a.place?.lat === 'number' && typeof a.place?.lng === 'number')
|
||||
.length,
|
||||
[storedAssignments])
|
||||
|
||||
/**
|
||||
* Which half of a split leg a via belongs to, measured on the road actually driven.
|
||||
@@ -1035,16 +1111,30 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [tripId, trip, can, roadtripPreferencesState.ready, toast, t])
|
||||
|
||||
const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => {
|
||||
const saveStopDraft = useCallback(async (
|
||||
{ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number },
|
||||
{ releaseStay = false }: { releaseStay?: boolean } = {},
|
||||
) => {
|
||||
if (!stopDraft) return
|
||||
const { poi, dayId, position } = stopDraft
|
||||
const accommodationId = stopDraft.editing?.accommodationId
|
||||
// Turning a booked night into a pause deletes the booking, and with it the
|
||||
// reservation and the expense the server keeps against it. The popup stays open
|
||||
// behind the question, so a no leaves the traveller exactly where they were.
|
||||
if (accommodationId && !releaseStay) {
|
||||
const stay = tripAccommodations.find(s => s.id === accommodationId)
|
||||
const booking = reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === accommodationId)?.title
|
||||
?? stay?.reservation_title ?? null
|
||||
setStayRelease({ stop: { stopType, dwellMinutes }, name: poi.name, booking })
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: stopType, duration_minutes: dwellMinutes })
|
||||
if (stopDraft.editing.accommodationId) {
|
||||
if (accommodationId) {
|
||||
// The night is what was switched off, not the stop: it stays where it is
|
||||
// in the drive and becomes an ordinary pause.
|
||||
applyStayStops(await accommodationsApi.delete(tripId, stopDraft.editing.accommodationId, { keepStop: true }))
|
||||
applyStayStops(await accommodationsApi.delete(tripId, accommodationId, { keepStop: true }))
|
||||
await loadAccommodations()
|
||||
}
|
||||
updateRouteForDay(dayId)
|
||||
@@ -1083,7 +1173,14 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
}
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations])
|
||||
}, [stopDraft, tripId, tripActions, updateRouteForDay, toast, t, roadtripVias, viaLiesBefore, loadAccommodations, tripAccommodations, reservations])
|
||||
|
||||
/** The yes to the question above: the same save, this time allowed to drop the night. */
|
||||
const confirmStayRelease = useCallback(async () => {
|
||||
const pending = stayRelease
|
||||
setStayRelease(null)
|
||||
if (pending) await saveStopDraft(pending.stop, { releaseStay: true })
|
||||
}, [stayRelease, saveStopDraft])
|
||||
|
||||
const saveStopDraftAsNight = useCallback(async ({ endDayId, checkIn, checkOut }: {
|
||||
endDayId: number
|
||||
@@ -1095,8 +1192,17 @@ export function useTripPlanner() {
|
||||
try {
|
||||
if (stopDraft.editing) {
|
||||
const booking = { place_id: stopDraft.editing.placeId, start_day_id: dayId, end_day_id: endDayId, check_in: checkIn || null, check_out: checkOut || null }
|
||||
if (stopDraft.editing.accommodationId) await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
|
||||
else await accommodationsApi.create(tripId, booking)
|
||||
// The answer carries the day stop the booking moved or added. The socket
|
||||
// deliberately skips the session that sent the request, so without folding
|
||||
// it in the one person whose rail still shows the night at its old place in
|
||||
// the chain is the one who just moved its check-in.
|
||||
const written = stopDraft.editing.accommodationId
|
||||
? await accommodationsApi.update(tripId, stopDraft.editing.accommodationId, booking)
|
||||
: await accommodationsApi.create(tripId, booking)
|
||||
applyStayStops(written)
|
||||
// A night seated by a new check-in renumbers its neighbours as well, and the
|
||||
// answer names only the night. The day is read back whole rather than guessed.
|
||||
if (written?.movedAssignment) await tripActions.refreshDays(tripId)
|
||||
await tripActions.updatePlace(tripId, stopDraft.editing.placeId, { stop_type: poi.category === 'campsite' ? 'campsite' : 'hotel' })
|
||||
await loadAccommodations()
|
||||
updateRouteForDay(dayId)
|
||||
@@ -1917,12 +2023,15 @@ export function useTripPlanner() {
|
||||
// leg each via was drawn for. A via is stored as (day, after_order_index) and
|
||||
// that index is a POSITION in the day's stop list, so a stop dropped into the
|
||||
// middle of a routed day pushes every via at or behind it onto the wrong leg and
|
||||
// the drawn road runs forward, doubles back and runs out again.
|
||||
const plan = insert && typeof data.lat === 'number' && typeof data.lng === 'number'
|
||||
// the drawn road runs forward, doubles back and runs out again. Keyed on the
|
||||
// position rather than on the service-stop form: a corridor hit handed to the
|
||||
// full form carries its position too, and was the one way into the middle of a
|
||||
// day that left the vias where they were.
|
||||
const plan = position != null && typeof data.lat === 'number' && typeof data.lng === 'number'
|
||||
? reanchorAfterInsert(
|
||||
roadtripVias.byDay[insert.dayId] ?? [],
|
||||
insert.position,
|
||||
viaLiesBefore(insert.dayId, { lat: data.lat, lng: data.lng }),
|
||||
roadtripVias.byDay[dayId] ?? [],
|
||||
position,
|
||||
viaLiesBefore(dayId, { lat: data.lat, lng: data.lng }),
|
||||
)
|
||||
: null
|
||||
try {
|
||||
@@ -2108,7 +2217,8 @@ export function useTripPlanner() {
|
||||
// the correction — and the predicate decides which side of the new stop a
|
||||
// via falls on when it is dropped into the middle of a leg.
|
||||
const stopsBefore = roadtripStopsOf(target)
|
||||
const insertAt = position === undefined ? stopsBefore.length : position
|
||||
// The position is a row index in the day list, the anchors count stops.
|
||||
const insertAt = position === undefined ? stopsBefore.length : roadtripIndexOf(target, position)
|
||||
const place = places.find(p => p.id === placeId)
|
||||
const plan = insertAt >= stopsBefore.length || typeof place?.lat !== 'number' || typeof place?.lng !== 'number'
|
||||
? null
|
||||
@@ -2130,7 +2240,7 @@ export function useTripPlanner() {
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, roadtripVias, roadtripStopsOf, viaLiesBefore])
|
||||
}, [selectedDayId, tripId, toast, updateRouteForDay, pushUndo, t, places, roadtripVias, roadtripStopsOf, roadtripIndexOf, viaLiesBefore])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId: number, assignmentId: number) => {
|
||||
const state = useTripStore.getState()
|
||||
@@ -2442,16 +2552,21 @@ export function useTripPlanner() {
|
||||
? inspectorStay(roadtripRoutes.days, endDayStop, selectedPlace, can('place_edit', trip) ? editRoadtripStay : undefined)
|
||||
: undefined
|
||||
|
||||
// Build placeId → order-number map from the selected day's assignments
|
||||
// Build placeId → order-number map from the selected day's assignments. A service
|
||||
// stop is passed over rather than counted: the map draws it without a badge and the
|
||||
// rail gives it no number, so a number spent on it left the pin after a fuel stop
|
||||
// wearing "3" where the rail said "2".
|
||||
const dayOrderMap = useMemo(() => {
|
||||
if (!selectedDayId) return {}
|
||||
const da = assignments[String(selectedDayId)] || []
|
||||
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
|
||||
const map = {}
|
||||
sorted.forEach((a, i) => {
|
||||
if (!a.place?.id) return
|
||||
let counted = 0
|
||||
sorted.forEach(a => {
|
||||
if (!a.place?.id || isServiceStopType(a.place.stop_type)) return
|
||||
counted += 1
|
||||
if (!map[a.place.id]) map[a.place.id] = []
|
||||
map[a.place.id].push(i + 1)
|
||||
map[a.place.id].push(counted)
|
||||
})
|
||||
return map
|
||||
}, [selectedDayId, assignments])
|
||||
@@ -2497,6 +2612,7 @@ export function useTripPlanner() {
|
||||
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
|
||||
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
|
||||
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
|
||||
stayRelease, setStayRelease, confirmStayRelease,
|
||||
setRoadtripStopKind,
|
||||
setRoadtripStopFill,
|
||||
roadtripEndsDayAt,
|
||||
@@ -2525,7 +2641,7 @@ export function useTripPlanner() {
|
||||
reservationPrefill, transportPrefill, importReviewActive, startImportReview, advanceImportReview,
|
||||
routeShown, setRouteShown, autoShowRoute, transitRoutesShown, routeProfile, setRouteProfile, routeVias, fitKey, setFitKey,
|
||||
mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds,
|
||||
deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, deletePlaceNote, deletePlacesNote,
|
||||
visibleConnections, toggleConnection, allConnectionsShown, toggleAllConnections, mapTransportDetail, setMapTransportDetail,
|
||||
isMobile, isTouch,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* FE-ROADTRIP-HAZARDREPO-001 to FE-ROADTRIP-HAZARDREPO-002: the one read behind
|
||||
* the hazard overlay.
|
||||
*
|
||||
* The server keeps the feed for ten minutes and rebuilds it on the first read
|
||||
* after that: the GDACS event list and then its polygons, each allowed 15 s.
|
||||
* At the shared 8 s the browser gave up on exactly that read, the overlay
|
||||
* reported the feed as unavailable, and nothing asked again for ten minutes,
|
||||
* by which time the cache had expired once more. These cases pin the wait the
|
||||
* read carries, that the caller's signal still cuts it short, and that the
|
||||
* answer is parsed rather than trusted.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { apiClient } from '../api/client'
|
||||
import { roadtripHazardsRepo, ROADTRIP_HAZARDS_TIMEOUT_MS } from './roadtripHazardsRepo'
|
||||
|
||||
vi.mock('../api/client', () => ({ apiClient: { get: vi.fn() } }))
|
||||
|
||||
const FEED = {
|
||||
fetchedAt: '2026-09-14T10:00:00.000Z',
|
||||
hazards: [
|
||||
{
|
||||
id: 'dwd-1',
|
||||
source: 'DWD',
|
||||
title: 'Sturmboeen',
|
||||
description: 'Boeen bis 85 km/h',
|
||||
updatedAt: '2026-09-14T10:00:00.000Z',
|
||||
validUntil: null,
|
||||
url: 'https://www.dwd.de/warnungen',
|
||||
geometry: { type: 'Point', coordinates: [13.4, 52.5] },
|
||||
},
|
||||
],
|
||||
sources: [
|
||||
{ source: 'DWD', status: 'ok' },
|
||||
{ source: 'GDACS', status: 'partial' },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: FEED })
|
||||
})
|
||||
|
||||
describe('roadtripHazardsRepo', () => {
|
||||
it('FE-ROADTRIP-HAZARDREPO-001: waits long enough for the server to rebuild the feed, on the caller signal', async () => {
|
||||
const signal = new AbortController().signal
|
||||
|
||||
await roadtripHazardsRepo.read(7, signal)
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/trips/7/roadtrip/hazards', { signal, timeout: ROADTRIP_HAZARDS_TIMEOUT_MS })
|
||||
// The event list and then the polygons, 15 s each, plus parsing.
|
||||
expect(ROADTRIP_HAZARDS_TIMEOUT_MS).toBeGreaterThanOrEqual(30_000)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-HAZARDREPO-002: parses the answer and refuses one that does not match the contract', async () => {
|
||||
expect(await roadtripHazardsRepo.read(7, new AbortController().signal)).toEqual(FEED)
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { fetchedAt: 'yesterday', hazards: [], sources: [] } })
|
||||
await expect(roadtripHazardsRepo.read(7, new AbortController().signal)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,22 @@
|
||||
import { roadtripHazardsSchema } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
/**
|
||||
* How long the browser waits for the hazard feed.
|
||||
*
|
||||
* The server keeps the feed for ten minutes and, once that is up, answers the
|
||||
* next request only after it has fetched DWD and GDACS afresh: the GDACS event
|
||||
* list and then up to twelve polygon lookups, each allowed 15 s. A cold read
|
||||
* takes nine to twenty seconds, so at the shared 8 s the browser gave up on
|
||||
* every first read after the cache expired, the map showed no warnings, and
|
||||
* the next attempt was ten minutes away although the server had the answer a
|
||||
* moment later. The bound is the sum of the server's own ceilings.
|
||||
*/
|
||||
export const ROADTRIP_HAZARDS_TIMEOUT_MS = 45_000
|
||||
|
||||
export const roadtripHazardsRepo = {
|
||||
async read(tripId: number, signal: AbortSignal) {
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal })
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal, timeout: ROADTRIP_HAZARDS_TIMEOUT_MS })
|
||||
return roadtripHazardsSchema.parse(fetched.data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -66,7 +66,12 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
|
||||
if (!place) return
|
||||
|
||||
const tempId = Date.now() * -1
|
||||
const current = [...(state.assignments[String(dayId)] || [])]
|
||||
// Sorted first, as moveAssignment does: the position is an index in the day as
|
||||
// it is shown, and that day is sorted by order_index. The stored list is not
|
||||
// always, a stop that arrived over the socket is appended wherever its
|
||||
// order_index says it belongs, so a splice into the raw list landed the new
|
||||
// row behind the wrong neighbour and the reorder that follows wrote that order.
|
||||
const current = (state.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const insertIdx = position != null ? position : current.length
|
||||
const tempAssignment: Assignment = {
|
||||
id: tempId,
|
||||
|
||||
@@ -16,9 +16,11 @@ export interface StayStopsResult {
|
||||
*
|
||||
* Booking a night also puts the place on its check-in day, because that stop is
|
||||
* what the road trip routes and the map draws. The server announces it over the
|
||||
* socket like any other assignment, but the socket deliberately skips the session
|
||||
* that sent the request (X-Socket-Id), so without this the one person who cannot
|
||||
* see their new stop is the one who just booked it. Goes through the same applier
|
||||
* socket like any other assignment, and unlike the booking events it does not
|
||||
* skip the session that sent the request: the stop and the order it was seated
|
||||
* into arrive there together. The answer carries the stop as well, for a session
|
||||
* whose socket is down at that moment, and applying it a second time is harmless
|
||||
* because the store drops a stop it already holds. Goes through the same applier
|
||||
* the socket uses, so the write-through to IndexedDB happens either way.
|
||||
*/
|
||||
export function applyStayStops(result: StayStopsResult | null | undefined): void {
|
||||
|
||||
@@ -25,7 +25,12 @@ export const useSystemNoticeStore = create<SystemNoticeState>()((set, get) => ({
|
||||
if (get().fetching || get().loaded) return;
|
||||
set({ fetching: true });
|
||||
try {
|
||||
const res = await axios.get('/system-notices/active');
|
||||
// Names the layouts this bundle can draw. The server holds the release notice
|
||||
// back from a bundle that does not say so: after an update the service worker
|
||||
// serves the previous bundle until the new one is installed, and that bundle
|
||||
// would draw the release notice as bare keys and let the reader dismiss it for
|
||||
// good. It gets the notice after the reload, from this very line.
|
||||
const res = await axios.get('/system-notices/active', { params: { supports: 'release' } });
|
||||
const notices = parseInDev(systemNoticeDtoSchema.array(), res.data, 'systemNotices.fetch');
|
||||
set({ notices, loaded: true, fetching: false });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-MOB-AADD-001 to FE-MOB-AADD-031
|
||||
// FE-MOB-AADD-001 to FE-MOB-AADD-032
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
@@ -242,6 +242,23 @@ describe('MAdminAddonManager', () => {
|
||||
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-032: the links row describes the feature, not an empty list', async () => {
|
||||
// The line under the row is its description. The links tab's empty state
|
||||
// read as a status here, and a status that never changes reads as a
|
||||
// broken feature.
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<MAdminAddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('Links');
|
||||
expect(screen.getByText('Shared links and bookmarks')).toBeInTheDocument();
|
||||
expect(screen.queryByText('No shared links yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AADD-013: collab sub-features stay hidden without the handler props', async () => {
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(<MAdminAddonManager />);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-031
|
||||
// FE-MOB-CCHAT-001 to FE-MOB-CCHAT-033
|
||||
|
||||
const ME = 7
|
||||
|
||||
@@ -264,6 +264,39 @@ describe('MCollabChat', () => {
|
||||
expect(input).toHaveValue('no network')
|
||||
})
|
||||
|
||||
// The strip shows the four pictures that will go out; the two the cap turned
|
||||
// away vanished without a word, and the sender believed all six were sent.
|
||||
it('FE-MOB-CCHAT-032: picking more images than fit on one message says so, and says it once', async () => {
|
||||
serveMessages([])
|
||||
let minted = 0
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`)
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const { planner } = await renderChat()
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' }))
|
||||
fireEvent.change(input, { target: { files } })
|
||||
|
||||
await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4))
|
||||
expect(planner.toast.error).toHaveBeenCalledTimes(1)
|
||||
expect(planner.toast.error).toHaveBeenCalledWith('collab.chat.imageLimit:4')
|
||||
expect(planner.toast.error).not.toHaveBeenCalledWith('collab.chat.imageRejected')
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-033: a file of the wrong kind is still turned away with the type message', async () => {
|
||||
serveMessages([])
|
||||
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:mock/1')
|
||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const { planner } = await renderChat()
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File([new Uint8Array(8)], 'notes.pdf', { type: 'application/pdf' })] } })
|
||||
|
||||
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('collab.chat.imageRejected'))
|
||||
expect(planner.toast.error).toHaveBeenCalledTimes(1)
|
||||
expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-CCHAT-015: auto-grows the composer and caps it at 100px', async () => {
|
||||
serveMessages([])
|
||||
await renderChat()
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TranslationFn } from '../../../../src/types'
|
||||
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
|
||||
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-048
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-049
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
@@ -640,4 +640,13 @@ describe('RtSpillRow', () => {
|
||||
expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/roadtrip\.spill\.departs/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-049: the departure follows the reader twelve hour setting like every other clock in the chain', () => {
|
||||
// The band sits directly above stops that print their clocks through the same
|
||||
// setting, so a raw 21:30 over a 9:45 PM is two clocks on one screen.
|
||||
render(<RtSpillRow fromDayNumber={1} departs="21:30" chrome={{ ...chrome, is12h: true }} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.spill.departs:9:30 PM')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.spill.departs:21:30')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { Day, Place } from '../../../../src/types'
|
||||
import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
|
||||
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-050
|
||||
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-053
|
||||
//
|
||||
// The stage bar pictures the place its day ends at. It reads that place out of the trip
|
||||
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
|
||||
@@ -284,6 +284,32 @@ describe('MRoadtripTab', () => {
|
||||
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-053: with the drive routed and no day picked it asks for a day, not for a route', () => {
|
||||
// The map's whole-trip view deselects the day and the list switch leaves it so.
|
||||
// The header keeps printing the drive's distance meanwhile, so "no route yet"
|
||||
// here contradicted the line right above it.
|
||||
renderTab(planner({ selectedDayId: null }))
|
||||
|
||||
expect(screen.getByText('mobileTrip.rtNoDay')).toBeInTheDocument()
|
||||
expect(screen.getByText('mobileTrip.rtNoDayHint')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtStart')).toBeNull()
|
||||
|
||||
// A picked day the routing has nothing for is still the empty stage: that one is
|
||||
// about the day, and the sentence about two places is the right one there.
|
||||
const unrouted = render(<MRoadtripTab planner={planner({ selectedDayId: 1 })} shell={buildShell()} tab="roadtrip" />)
|
||||
expect(within(unrouted.container).getByText('roadtrip.empty.title')).toBeInTheDocument()
|
||||
expect(within(unrouted.container).queryByText('mobileTrip.rtNoDay')).toBeNull()
|
||||
|
||||
// And while the round is still running, no day picked is still the partial hint.
|
||||
const running = render(
|
||||
<MRoadtripTab planner={planner({ selectedDayId: null, roadtripRoutes: routes({ loading: true }) })} shell={buildShell()} tab="roadtrip" />,
|
||||
)
|
||||
expect(within(running.container).getByText('roadtrip.summary.partial')).toBeInTheDocument()
|
||||
expect(within(running.container).queryByText('mobileTrip.rtNoDay')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTTAB-010: draws no day colour dot in the head card, even with the day colours on', () => {
|
||||
// The colour keys the day's line on the map, which lies under the list on this half,
|
||||
// so a dot here had nothing on screen to explain it.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-028
|
||||
// FE-MOB-SHOST-001 to FE-MOB-SHOST-029
|
||||
//
|
||||
// Every child sheet is stubbed: this file is about the host — which sheet is
|
||||
// mounted for which shell.sheet id, and how the host's own callbacks wire the
|
||||
@@ -425,6 +425,18 @@ describe('MTripSheets', () => {
|
||||
expect(planner.setDeletePlaceId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-029: a night booked at the place is said before the yes, and only then', () => {
|
||||
// The server takes the night down with the place, and the booking and the
|
||||
// expense with the night. The planner builds the sentence; the sheet has to
|
||||
// show it under the question.
|
||||
const note = 'The booking at Hotel Okura and its expense go with it.'
|
||||
renderHost({ deletePlaceId: 101, deletePlaceNote: note })
|
||||
|
||||
const confirm = screen.getByTestId('stub-confirm')
|
||||
expect(confirm).toHaveTextContent('trip.confirm.deletePlace')
|
||||
expect(screen.getByText(note)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-SHOST-026: cancelling the confirm only disarms the flag', () => {
|
||||
const { planner } = renderHost({ deletePlaceId: 101 })
|
||||
fireEvent.click(screen.getByText('cancel delete'))
|
||||
|
||||
@@ -173,6 +173,35 @@ describe('assignmentsSlice', () => {
|
||||
expect(useTripStore.getState().assignments['1'].map(a => a.id)).toEqual([999, 1]);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-023: the position counts in the day as sorted, not as the store happens to hold it', async () => {
|
||||
// A row that came in over the socket sits at the end of the stored list whatever
|
||||
// its order_index says, so the day the planner shows and the list the splice
|
||||
// ran on disagreed and the new row landed behind the wrong neighbour.
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
const later = buildAssignment({ id: 2, day_id: 1, order_index: 1 });
|
||||
const first = buildAssignment({ id: 1, day_id: 1, order_index: 0 });
|
||||
seedStore(useTripStore, { places: [place], assignments: { '1': [later, first] } });
|
||||
|
||||
let reorderBody: number[] | undefined;
|
||||
server.use(
|
||||
http.post('/api/trips/1/days/1/assignments', () =>
|
||||
HttpResponse.json({ assignment: buildAssignment({ id: 999, day_id: 1, place_id: 10, place, order_index: 7 }) })
|
||||
),
|
||||
http.put('/api/trips/1/days/1/assignments/reorder', async ({ request }) => {
|
||||
const body = await request.json() as { orderedIds: number[] };
|
||||
reorderBody = body.orderedIds;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
|
||||
await useTripStore.getState().assignPlaceToDay(1, 1, 10, 1);
|
||||
|
||||
const items = useTripStore.getState().assignments['1'];
|
||||
expect(items.map(a => a.id)).toEqual([1, 999, 2]);
|
||||
expect(items.map(a => a.order_index)).toEqual([0, 1, 2]);
|
||||
expect(reorderBody).toEqual([1, 999, 2]);
|
||||
});
|
||||
|
||||
it('FE-ASSIGN-012: no reorder call when the day holds no server-side ids', async () => {
|
||||
const place = buildPlace({ id: 10, trip_id: 1 });
|
||||
seedStore(useTripStore, { places: [place], assignments: { '1': [] } });
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { useSystemNoticeStore } from '../../../src/store/systemNoticeStore';
|
||||
|
||||
const initial = useSystemNoticeStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
useSystemNoticeStore.setState(initial, true);
|
||||
});
|
||||
|
||||
describe('systemNoticeStore', () => {
|
||||
// After an update the service worker serves the previous bundle until the new one is
|
||||
// installed. The server only hands the release notice to a bundle that announces it
|
||||
// can draw the release layout, so an older bundle never shows it as bare keys and
|
||||
// never dismisses it for good. This bundle has to announce it on every fetch.
|
||||
describe('FE-SYSNOTICE-001: fetch() announces the layouts this bundle can draw', () => {
|
||||
it('sends supports=release with the active-notices request', async () => {
|
||||
const urls: URL[] = [];
|
||||
server.use(
|
||||
http.get('/api/system-notices/active', ({ request }) => {
|
||||
urls.push(new URL(request.url));
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
|
||||
await useSystemNoticeStore.getState().fetch();
|
||||
|
||||
expect(urls).toHaveLength(1);
|
||||
expect(urls[0].searchParams.get('supports')).toBe('release');
|
||||
expect(useSystemNoticeStore.getState().loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the notices the server returns, release layout included', async () => {
|
||||
const release = {
|
||||
id: 'release-notes', display: 'modal', severity: 'info', dismissible: true,
|
||||
titleKey: 'system_notice.release_notes.headline', bodyKey: 'system_notice.release_notes.intro',
|
||||
release: {
|
||||
version: '4.3.0',
|
||||
eyebrowKey: 'system_notice.release_notes.eyebrow',
|
||||
headlineKey: 'system_notice.release_notes.headline',
|
||||
introKey: 'system_notice.release_notes.intro',
|
||||
featuresLabelKey: 'system_notice.release_notes.features_label',
|
||||
features: [],
|
||||
note: {
|
||||
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
|
||||
titleKey: 'system_notice.release_notes.note_title',
|
||||
bodyKey: 'system_notice.release_notes.note_body',
|
||||
promiseLabelKey: 'system_notice.release_notes.promise_label',
|
||||
promiseLeadKey: 'system_notice.release_notes.promise_lead',
|
||||
promiseTextKey: 'system_notice.release_notes.promise_text',
|
||||
bodyAfterKey: 'system_notice.release_notes.note_body_after',
|
||||
closingKey: 'system_notice.release_notes.note_closing',
|
||||
},
|
||||
supportLeadKey: 'system_notice.release_notes.support_lead',
|
||||
supportTextKey: 'system_notice.release_notes.support_text',
|
||||
},
|
||||
};
|
||||
server.use(
|
||||
http.get('/api/system-notices/active', () => HttpResponse.json([release])),
|
||||
);
|
||||
|
||||
await useSystemNoticeStore.getState().fetch();
|
||||
|
||||
const state = useSystemNoticeStore.getState();
|
||||
expect(state.loaded).toBe(true);
|
||||
expect(state.notices.map(n => n.id)).toEqual(['release-notes']);
|
||||
expect(state.notices[0].release?.version).toBe('4.3.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,11 @@ services:
|
||||
# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000)
|
||||
# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off)
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
|
||||
# - TREK_PLACES_URL=https://places.liketrek.com # Optional. Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
# - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
|
||||
# - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
|
||||
# - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
@@ -60,8 +60,8 @@ There are **no database rows for notice definitions**. The registry is code-only
|
||||
3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch()
|
||||
│ (also triggered on cold page reload if store not yet loaded)
|
||||
▼
|
||||
4. GET /api/system-notices/active
|
||||
│
|
||||
4. GET /api/system-notices/active?supports=release
|
||||
│ (`supports` lists the layouts this bundle can draw; see section 10)
|
||||
▼
|
||||
5. service.getActiveNoticesFor(userId)
|
||||
├── reads user row (login_count, first_seen_version, role)
|
||||
@@ -75,6 +75,10 @@ There are **no database rows for notice definitions**. The registry is code-only
|
||||
└── strips server-only fields (conditions, publishedAt, minVersion, maxVersion, priority)
|
||||
│
|
||||
▼
|
||||
Nest SystemNoticesService drops every notice with a `release` block unless the
|
||||
request announced `supports=release`
|
||||
│
|
||||
▼
|
||||
6. Client receives SystemNoticeDTO[]
|
||||
│
|
||||
▼
|
||||
@@ -500,7 +504,7 @@ system_notice.pager.position → "Notice {current} of {total}" (aria-live)
|
||||
|
||||
| Action | Behaviour |
|
||||
|---|---|
|
||||
| `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. |
|
||||
| `fetch()` | `GET /api/system-notices/active?supports=release`. Fails silently (non-critical). Sets `loaded = true` regardless. `supports` names the layouts this bundle can draw: after an update the service worker keeps serving the previous bundle until the new one is installed, and a bundle that does not announce the release layout would draw the release notice as bare keys and could dismiss it for good. The server holds a notice with a `release` block back from such a client and delivers it after the reload. |
|
||||
| `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. |
|
||||
|
||||
`SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once.
|
||||
|
||||
+8
-1
@@ -53,7 +53,14 @@ DEMO_MODE=false # Demo mode - resets data hourly
|
||||
# NOMINATIM_URL=https://nominatim.openstreetmap.org # Custom HTTP(S) Nominatim base URL for every geocoding call the server makes: place search, reverse lookup, place details and the Atlas region fill. When set it REPLACES the public service with no fallback, so whatever answers has to speak the Nominatim API (/search, /reverse, /lookup) and hold the coverage you expect. TREK keeps its ~1 request per second spacing whichever instance answers. Unset/blank uses the public default.
|
||||
|
||||
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
|
||||
# OVERPASS_TIMEOUT_MS=12000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. Raise it for a slow self-hosted instance. (default: 12000)
|
||||
# OVERPASS_TIMEOUT_MS=25000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming. Raise it for a slow self-hosted instance. (default: 25000)
|
||||
|
||||
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding and offline downloads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
|
||||
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
|
||||
# AMAP_API_KEY= # Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
|
||||
# AMAP_API_SECRET= # The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
|
||||
# AMAP_API_BASE=https://restapi.amap.com # Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
|
||||
|
||||
# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected)
|
||||
|
||||
|
||||
@@ -554,6 +554,44 @@ async function main() {
|
||||
db.prepare('UPDATE trek_photos SET passphrase = ? WHERE id = ?').run(newVal, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- document_connections: secrets ---
|
||||
// Every credential of a document connection sits in ONE encrypted JSON blob
|
||||
// (src/nest/doc-sync/doc-sync-secrets.ts) rather than in a column per
|
||||
// provider, so a new provider changes nothing here: the blob is rotated as
|
||||
// an opaque value. It also holds secrets the provider handed out itself,
|
||||
// such as the DSM device token, which no form can re-enter. Left out of a
|
||||
// rotation, every trip binding on the instance reads back as unauthorized
|
||||
// and Synology needs a fresh OTP pairing. The table arrived with a later
|
||||
// migration, hence the existence check.
|
||||
if (tableExists('document_connections')) {
|
||||
const documentConnections = db
|
||||
.prepare('SELECT id, secrets FROM document_connections WHERE secrets IS NOT NULL')
|
||||
.all() as { id: number; secrets: string }[];
|
||||
for (const row of documentConnections) {
|
||||
const newVal = migrateApiKeyValue(row.secrets, `document_connections[${row.id}].secrets`);
|
||||
if (newVal !== null) {
|
||||
db.prepare('UPDATE document_connections SET secrets = ? WHERE id = ?').run(newVal, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- trip_document_links: webhook_secret ---
|
||||
// The secret a provider signs its webhook calls with, stored through the
|
||||
// same blob helper. It is written once when the link is created and never
|
||||
// regenerated, so a rotation that misses it leaves the link with nothing to
|
||||
// check a signature against.
|
||||
if (tableExists('trip_document_links')) {
|
||||
const documentLinks = db
|
||||
.prepare('SELECT id, webhook_secret FROM trip_document_links WHERE webhook_secret IS NOT NULL')
|
||||
.all() as { id: number; webhook_secret: string }[];
|
||||
for (const row of documentLinks) {
|
||||
const newVal = migrateApiKeyValue(row.webhook_secret, `trip_document_links[${row.id}].webhook_secret`);
|
||||
if (newVal !== null) {
|
||||
db.prepare('UPDATE trip_document_links SET webhook_secret = ? WHERE id = ?').run(newVal, row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -151,8 +151,14 @@ export function deriveMaps(raw: RawEnv) {
|
||||
* whether searches leave the instance, and a setting that reaches for the
|
||||
* network is one an operator wants pinned in their compose file, not one a
|
||||
* second admin can flip in a browser.
|
||||
*
|
||||
* Off only on a value of the false family the schema admits (false, 0,
|
||||
* off, no), so the switch reads like every other boolean here. Unset,
|
||||
* blank and anything else stay on: the schema refuses a value outside the
|
||||
* family at boot, and a path that skips validation must not quietly drop
|
||||
* the install back to Nominatim.
|
||||
*/
|
||||
trekPlacesEnabled: raw.TREK_PLACES_ENABLED !== 'false',
|
||||
trekPlacesEnabled: parseBool(raw.TREK_PLACES_ENABLED) !== false,
|
||||
placesApiKey: raw.PLACES_API_KEY || undefined,
|
||||
/** The same two knobs for Amap (高德), which an install in China uses instead. */
|
||||
amapApiBase: raw.AMAP_API_BASE || undefined,
|
||||
|
||||
@@ -148,9 +148,11 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
* A list file is the same story at a smaller scale. Its contract allows a
|
||||
* megabyte and the import posts it whole, yet a list of a few hundred places
|
||||
* with notes is past a hundred kilobytes already, and so is the favourites
|
||||
* GPX of anybody who uses OsmAnd (#2301). The two routes that carry one are
|
||||
* measured against that megabyte, doubled for the JSON escaping of a
|
||||
* document full of quotes. Everything else keeps the tighter limit.
|
||||
* GPX of anybody who uses OsmAnd (#2301). The three routes that carry one,
|
||||
* the import into a new list, the GPX reader and the import into a list that
|
||||
* already exists, are measured against that megabyte, doubled for the JSON
|
||||
* escaping of a document full of quotes. Everything else keeps the tighter
|
||||
* limit.
|
||||
*/
|
||||
const bookBody = express.json({ limit: '8mb', verify: rawBodyKeeper });
|
||||
const listFileBody = express.json({ limit: MAX_COLLECTION_FILE_BYTES * 2, verify: rawBodyKeeper });
|
||||
@@ -159,8 +161,8 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/';
|
||||
const isBookWrite = (req: Request) =>
|
||||
req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path);
|
||||
const listFilePaths = new Set(['/api/addons/collections/import', '/api/addons/collections/gpx/read']);
|
||||
const isListFile = (req: Request) => req.method === 'POST' && listFilePaths.has(req.path);
|
||||
const isListFile = (req: Request) =>
|
||||
req.method === 'POST' && /^\/api\/addons\/collections\/(import|gpx\/read|\d+\/import)$/.test(req.path);
|
||||
|
||||
instance.use(function jsonParser(req: Request, res: Response, next: NextFunction) {
|
||||
if (isBookWrite(req)) return bookBody(req, res, next);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { AccommodationsService } from './accommodations.service';
|
||||
import { AccommodationsService, type MirrorSender } from './accommodations.service';
|
||||
import { AccommodationCreateDto, AccommodationUpdateDto } from './accommodations.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
@@ -39,10 +39,10 @@ type AccommodationBody = {
|
||||
* accommodation also emits reservation:created; a delete emits the linked
|
||||
* reservation/budget deletions) with the forwarded X-Socket-Id.
|
||||
*
|
||||
* A booking also writes the day stop that puts it on the route, so the answers
|
||||
* carry that stop alongside the existing fields: the broadcast skips the socket
|
||||
* that sent the request, and the client that booked the night has to be able to
|
||||
* draw it without waiting for a reload.
|
||||
* A booking also writes the day stop that puts it on the route. What that did to
|
||||
* the day plan goes to every socket, the sender's included (see mirrorSender),
|
||||
* and the answers carry the stop alongside the existing fields as well, for a
|
||||
* session whose socket is down at that moment.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/accommodations')
|
||||
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
|
||||
@@ -52,7 +52,20 @@ type AccommodationBody = {
|
||||
export class AccommodationsController {
|
||||
constructor(private readonly accommodations: AccommodationsService) {}
|
||||
|
||||
|
||||
/**
|
||||
* The mirror's door out, with no socket to skip.
|
||||
*
|
||||
* The booking events above it are echo-suppressed because the client already drew
|
||||
* what it sent. It never sent the day plan's side of the write: the stop is in the
|
||||
* answer, but the order the night was seated into and the vias re-pinned around it
|
||||
* are not, and a session that applies the stop from the answer alone keeps the
|
||||
* stops behind it on their old numbers until a reload. Over the socket the stop
|
||||
* and the order arrive together and in order, and the answer's copy of the stop
|
||||
* is a duplicate the store already drops.
|
||||
*/
|
||||
private mirrorSender(tripId: string): MirrorSender {
|
||||
return (event, payload) => this.accommodations.broadcast(tripId, event, payload, undefined);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
@@ -79,10 +92,9 @@ export class AccommodationsController {
|
||||
const { accommodation, mirror } = this.accommodations.create(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:created', { accommodation }, socketId);
|
||||
this.accommodations.broadcast(tripId, 'reservation:created', {}, socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
// The stop rides in the answer as well: the broadcast above deliberately skips
|
||||
// the socket that sent the request, so without it the very session that booked
|
||||
// the night would be the one that cannot see it until a reload.
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
// The stop rides in the answer as well, for the session that booked the night
|
||||
// with its socket down: over the socket it would already have it.
|
||||
return { accommodation, assignment: mirror.created };
|
||||
}
|
||||
|
||||
@@ -107,7 +119,7 @@ export class AccommodationsController {
|
||||
}
|
||||
const { accommodation, mirror } = this.accommodations.update(id, existing as never, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } as never);
|
||||
this.accommodations.broadcast(tripId, 'accommodation:updated', { accommodation }, socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
return { accommodation, assignment: mirror.created, movedAssignment: mirror.moved, removedAssignments: mirror.removed };
|
||||
}
|
||||
|
||||
@@ -127,7 +139,7 @@ export class AccommodationsController {
|
||||
// stop stays and becomes the traveller's. Everywhere else a cancelled booking
|
||||
// takes the stop it brought with it.
|
||||
const { linkedReservationIds, deletedBudgetItemIds, mirror } = this.accommodations.remove(id, { keepStop: keepStop === 'true' });
|
||||
this.accommodations.announceMirror(tripId, mirror, (event, payload) => this.accommodations.broadcast(tripId, event, payload, socketId), socketId);
|
||||
this.accommodations.announceMirror(tripId, mirror, this.mirrorSender(tripId), socketId);
|
||||
for (const reservationId of linkedReservationIds) {
|
||||
this.accommodations.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import type { RoadtripVia, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { DatabaseService, type PlaceWithTags, type TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
@@ -10,6 +10,12 @@ type Trip = TripAccess;
|
||||
|
||||
type MirroredAssignment = ReturnType<AssignmentsService['createAssignment']>;
|
||||
|
||||
/** One day's drawn roads after a write re-pinned them, as the road trip broadcasts them. */
|
||||
type DayVias = { dayId: number; vias: RoadtripVia[] };
|
||||
|
||||
/** A via as the re-pinning reads it: where it is pinned and its place on that leg. */
|
||||
type PinnedVia = { id: number; after_order_index: number; sequence: number };
|
||||
|
||||
/** How a surface sends the mirror's events; see announceMirror. */
|
||||
export type MirrorSender = <E extends TrekWsTripEventName>(event: E, payload: TrekWsPayload<E>) => void;
|
||||
|
||||
@@ -25,6 +31,9 @@ export interface AccommodationMirror {
|
||||
removed: { id: number; dayId: number }[];
|
||||
/** The place, when this write was the one that typed it as lodging. */
|
||||
stamped: PlaceWithTags | null;
|
||||
/** Days whose drawn roads were re-pinned because a stop of theirs changed position.
|
||||
* Absent when none did, which is what a mirror built elsewhere means too. */
|
||||
vias?: DayVias[];
|
||||
}
|
||||
|
||||
/** A write that left the day plan alone. Exported for the surfaces that write a
|
||||
@@ -169,9 +178,11 @@ export class AccommodationsService {
|
||||
* Send what a stay write did to the day plan, and let the journey skeletons
|
||||
* catch up the way an assignment route does.
|
||||
*
|
||||
* Takes the sender rather than broadcasting itself: REST and the plugin RPC
|
||||
* hand their socket id in, the MCP tools tag their events, and the fan-out is
|
||||
* the one part that must not exist in three copies.
|
||||
* Takes the sender rather than broadcasting itself: the MCP tools tag their
|
||||
* events, REST and the plugin RPC send them plain, and the fan-out is the one
|
||||
* part that must not exist in three copies. None of them skips the socket that
|
||||
* sent the request. The day order and the vias sent here are news to that
|
||||
* session too, and they only make sense arriving behind the stop they concern.
|
||||
*/
|
||||
announceMirror(tripId: string | number, mirror: AccommodationMirror, send: MirrorSender, socketId?: string): void {
|
||||
for (const stop of mirror.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
|
||||
@@ -194,6 +205,9 @@ export class AccommodationsService {
|
||||
'SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index', dayId).map(row => row.id);
|
||||
send('assignment:reordered', { dayId, orderedIds });
|
||||
}
|
||||
// After the order, the way the time sort sends them: the planner routes the
|
||||
// anchors it holds against the order it holds, and the two have to land together.
|
||||
for (const day of mirror.vias ?? []) send('roadtripVia:changed', day);
|
||||
|
||||
if (mirror.created || mirror.moved || mirror.removed.length > 0) this.assignments.reconcile(tripId, socketId);
|
||||
}
|
||||
@@ -324,6 +338,121 @@ export class AccommodationsService {
|
||||
return this.db.getPlaceWithTags(placeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Each day's located stops in order, taken before a write that can move them.
|
||||
*
|
||||
* That order is the index space the day's vias are pinned to: a via sits behind
|
||||
* the n-th stop that has coordinates, not behind a row id. Stops without
|
||||
* coordinates are never routed and so never counted.
|
||||
*/
|
||||
private stopOrders(dayIds: number[]): Map<number, number[]> {
|
||||
return new Map([...new Set(dayIds)].map((dayId): [number, number[]] => [dayId, this.locatedStopIds(dayId)]));
|
||||
}
|
||||
|
||||
private locatedStopIds(dayId: number): number[] {
|
||||
return this.db.all<{ id: number }>(`
|
||||
SELECT da.id FROM day_assignments da JOIN places p ON p.id = da.place_id
|
||||
WHERE da.day_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL
|
||||
ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC
|
||||
`, dayId).map(row => row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep every drawn road behind the stop it was drawn after, now that this write
|
||||
* has seated, moved or taken out a stop on these days.
|
||||
*
|
||||
* A night seated by its check-in ahead of the afternoon renumbers everything
|
||||
* behind it, and a via pinned to position one would otherwise bend the drive
|
||||
* into the hotel instead of the leg it was drawn on. Persisted and visible to
|
||||
* everyone, so it is put right where the stop moved, by the rules the planner
|
||||
* applies when a stop is dragged or taken out: a via follows its stop, a stop
|
||||
* that left the day hands its road to the stop before it, and a stop that is
|
||||
* last has no leg to keep a via on.
|
||||
*
|
||||
* Runs inside the caller's transaction.
|
||||
*/
|
||||
private reanchorVias(mirror: AccommodationMirror, before: Map<number, number[]>): void {
|
||||
for (const [dayId, previousIds] of before) {
|
||||
const nextIds = this.locatedStopIds(dayId);
|
||||
if (previousIds.length === nextIds.length && previousIds.every((id, i) => id === nextIds[i])) continue;
|
||||
const vias = this.db.all<PinnedVia>('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?', dayId);
|
||||
if (!vias.length) continue;
|
||||
|
||||
// A via behind the day's last stop bends the drive into the next day. It stays
|
||||
// with that stop when the stop is still last, whatever its number is now:
|
||||
// measured as a leg it would be dropped, because a last stop has no leg.
|
||||
const previousLast = previousIds.length - 1;
|
||||
const nextLast = nextIds.length - 1;
|
||||
const lastStayed = previousLast >= 0 && previousIds[previousLast] === nextIds[nextLast];
|
||||
const remove: number[] = [];
|
||||
const moved: { id: number; after_order_index: number }[] = [];
|
||||
for (const via of vias) {
|
||||
const next = lastStayed && via.after_order_index === previousLast
|
||||
? nextLast
|
||||
: this.legAfter(via.after_order_index, previousIds, nextIds);
|
||||
if (next === null) remove.push(via.id);
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next });
|
||||
}
|
||||
if (!remove.length && !moved.length) continue;
|
||||
|
||||
for (const viaId of remove) this.db.run('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?', viaId, dayId);
|
||||
for (const via of moved) this.db.run('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?', via.after_order_index, via.id, dayId);
|
||||
this.renumberMergedLegs(dayId, vias.filter(via => !remove.includes(via.id)), moved);
|
||||
this.noteVias(mirror, {
|
||||
dayId,
|
||||
vias: this.db.all<RoadtripVia>(
|
||||
'SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id', dayId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a via pinned behind the n-th stop of the old order is on in the new one.
|
||||
*
|
||||
* A stop this write took off the day hands its road to the stop before it: the
|
||||
* leg it was drawn on merges into the one ahead, the way taking a stop out of the
|
||||
* drive merges them (`RoadtripService.reanchor`). Null when no leg is left for it,
|
||||
* because the stop it follows is the last one now, nothing ahead of it survived,
|
||||
* or it was pinned past the day's end to begin with.
|
||||
*/
|
||||
private legAfter(index: number, previousIds: number[], nextIds: number[]): number | null {
|
||||
if (index > previousIds.length - 1) return null;
|
||||
let at = index;
|
||||
while (at >= 0 && !nextIds.includes(previousIds[at])) at -= 1;
|
||||
if (at < 0) return null;
|
||||
const next = nextIds.indexOf(previousIds[at]);
|
||||
return next >= nextIds.length - 1 ? null : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two legs that merged carry two sequence series side by side, and everything
|
||||
* that draws the route orders by sequence: the drive would run through the first
|
||||
* leg's point, the second leg's, and back. Renumbered the way
|
||||
* `RoadtripService.reanchor` does it, the earlier leg's points first, then by
|
||||
* their old sequence, then by id. Only a leg that received a via is touched.
|
||||
*/
|
||||
private renumberMergedLegs(dayId: number, kept: PinnedVia[], moved: { id: number; after_order_index: number }[]): void {
|
||||
const landed = new Map(moved.map(via => [via.id, via.after_order_index]));
|
||||
const byLeg = new Map<number, PinnedVia[]>();
|
||||
for (const via of kept) {
|
||||
const leg = landed.get(via.id) ?? via.after_order_index;
|
||||
byLeg.set(leg, [...(byLeg.get(leg) ?? []), via]);
|
||||
}
|
||||
for (const onLeg of byLeg.values()) {
|
||||
if (!onLeg.some(via => landed.has(via.id))) continue;
|
||||
onLeg.sort((a, b) => a.after_order_index - b.after_order_index || a.sequence - b.sequence || a.id - b.id);
|
||||
onLeg.forEach((via, index) => {
|
||||
if (via.sequence !== index) this.db.run('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?', index, via.id, dayId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry per day: a write that takes a stop off a day and puts one back on
|
||||
* the same day reports the state it left behind, not both steps. */
|
||||
private noteVias(mirror: AccommodationMirror, day: DayVias): void {
|
||||
mirror.vias = [...(mirror.vias ?? []).filter(known => known.dayId !== day.dayId), day];
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry the booking's own stop to where the booking now is, in place.
|
||||
*
|
||||
@@ -389,6 +518,7 @@ export class AccommodationsService {
|
||||
// answer for a place already planned for that day by hand.
|
||||
if (this.db.get('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?', dayId, placeId)) return mirror;
|
||||
|
||||
const before = this.stopOrders([dayId]);
|
||||
// Through AssignmentsService, so the stop lands at the end of the day with the
|
||||
// order_index every other new assignment gets. Evening is where you arrive at a
|
||||
// hotel, and the day planner has no drive to position it against anyway.
|
||||
@@ -402,6 +532,7 @@ export class AccommodationsService {
|
||||
accommodationId,
|
||||
orderIndex: this.positionForCheckIn(dayId, checkIn),
|
||||
});
|
||||
this.reanchorVias(mirror, before);
|
||||
return mirror;
|
||||
}
|
||||
|
||||
@@ -428,7 +559,9 @@ export class AccommodationsService {
|
||||
*/
|
||||
private releaseStops(accommodationId: number, opts: { keepStop?: boolean }): AccommodationMirror {
|
||||
const mirror = noMirror();
|
||||
for (const stop of this.ownStops(accommodationId)) {
|
||||
const own = this.ownStops(accommodationId);
|
||||
const before = this.stopOrders(own.map(stop => stop.day_id));
|
||||
for (const stop of own) {
|
||||
if (opts.keepStop) {
|
||||
this.db.run('UPDATE day_assignments SET accommodation_id = NULL WHERE id = ?', stop.id);
|
||||
// The stop stays, but it is the traveller's now. Days hides a stop whose
|
||||
@@ -441,6 +574,7 @@ export class AccommodationsService {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
|
||||
}
|
||||
this.reanchorVias(mirror, before);
|
||||
return mirror;
|
||||
}
|
||||
|
||||
@@ -473,21 +607,26 @@ export class AccommodationsService {
|
||||
// note, its hour, its end-of-day flag and the road-trip day boundary anchored
|
||||
// on its id, all of which a DELETE takes with it.
|
||||
if (own.length === 1 && placeId) {
|
||||
const before = this.stopOrders([own[0].day_id, dayId]);
|
||||
const moved = this.relocateOwnStop(own[0], placeId, dayId, checkIn);
|
||||
if (moved) {
|
||||
mirror.moved = { assignment: moved, oldDayId: own[0].day_id };
|
||||
mirror.stamped = this.stampLodging(placeId);
|
||||
this.reanchorVias(mirror, before);
|
||||
return mirror;
|
||||
}
|
||||
}
|
||||
|
||||
const beforeRebuild = this.stopOrders(own.map(stop => stop.day_id));
|
||||
for (const stop of own) {
|
||||
this.db.run('DELETE FROM day_assignments WHERE id = ?', stop.id);
|
||||
mirror.removed.push({ id: stop.id, dayId: stop.day_id });
|
||||
}
|
||||
this.reanchorVias(mirror, beforeRebuild);
|
||||
const fresh = this.mirrorStay(accommodationId, placeId, dayId, checkIn);
|
||||
mirror.created = fresh.created;
|
||||
mirror.stamped = fresh.stamped;
|
||||
for (const day of fresh.vias ?? []) this.noteVias(mirror, day);
|
||||
return mirror;
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,11 @@ export class CalendarService {
|
||||
for (const day of days) {
|
||||
if (!day.date) continue;
|
||||
|
||||
// A booked night puts a stop of its own on its check-in day, so the route
|
||||
// can reach the hotel. That stop is the booking, not a place the traveller
|
||||
// planned to visit, and the booking already comes through below as the
|
||||
// stay block or its check-in and check-out markers. Read here as well it
|
||||
// would put the hotel on the day a second time.
|
||||
const assignments = this.db.prepare(`
|
||||
SELECT da.*, p.name as place_name, p.address as place_address,
|
||||
p.lat as place_lat, p.lng as place_lng,
|
||||
@@ -249,6 +254,7 @@ export class CalendarService {
|
||||
FROM day_assignments da
|
||||
JOIN places p ON da.place_id = p.id
|
||||
WHERE da.day_id = ?
|
||||
AND da.accommodation_id IS NULL
|
||||
ORDER BY da.order_index ASC, da.created_at ASC
|
||||
`).all(day.id) as any[];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkSsrf } from '../../utils/ssrfGuard';
|
||||
import { DocumentProviderRegistry } from './document-provider.registry';
|
||||
import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider';
|
||||
import { docFailed } from './document-provider';
|
||||
import { sameOrigin } from './doc-sync.helpers';
|
||||
import { decryptSecrets, encryptSecrets, maskSecrets, mergeSecrets } from './doc-sync-secrets';
|
||||
|
||||
/**
|
||||
@@ -239,9 +240,23 @@ export class DocSyncConfigService {
|
||||
* Secrets that arrive blank or masked keep their stored value, so a client
|
||||
* that renders the form from a GET never has to hold the real credential.
|
||||
* A secret the provider earned itself is kept too, whatever the form sends.
|
||||
* DSM's device token is stored with the address and account it was issued
|
||||
* for, so an edit that points the connection somewhere else leaves it
|
||||
* unusable, and the adapter drops it on its next call.
|
||||
*
|
||||
* Only for the address the credential was stored against, the same rule the
|
||||
* probe route applies. Written under a new host, a blank form would carry
|
||||
* the stored token to a server of the caller's choosing: a trip changes
|
||||
* hands with its connection, and the new owner, or an instance admin, could
|
||||
* point the previous owner's Paperless token at a machine of their own and
|
||||
* read it off the first request. A new address takes the credentials typed
|
||||
* for it and nothing stored, DSM's device token included: it was issued for
|
||||
* the old address and account and would only be sent somewhere it does not
|
||||
* belong.
|
||||
*
|
||||
* `owner_user_id` names whose credential the connection runs under. It moves
|
||||
* to the caller only when the caller typed every required secret in, so the
|
||||
* connection now runs on a credential of their own; an edit that keeps the
|
||||
* stored one keeps its owner, or the orphan check would read the previous
|
||||
* owner's token as the caller's own. An optional secret alone (DSM's
|
||||
* one-time code) does not count: it is used against the stored password.
|
||||
*/
|
||||
async upsertConnection(
|
||||
tripId: number,
|
||||
@@ -260,7 +275,23 @@ export class DocSyncConfigService {
|
||||
.get(tripId, input.providerId) as ConnectionRow | undefined;
|
||||
|
||||
const storedSecrets = existing ? decryptSecrets(existing.secrets) : {};
|
||||
const nextSecrets = mergeSecrets(storedSecrets, input.credentials, secretKeys);
|
||||
// What the form actually filled in, by the same rule the merge below uses.
|
||||
const typedSecrets = mergeSecrets({}, input.credentials, secretKeys);
|
||||
const sameTarget = !existing || sameOrigin(existing.base_url, urlCheck.data.url);
|
||||
if (!sameTarget) {
|
||||
const kept = fields.find(
|
||||
(f) => f.secret === 1 && f.required === 1 && !!storedSecrets[f.field_key] && !typedSecrets[f.field_key],
|
||||
);
|
||||
if (kept) {
|
||||
return {
|
||||
success: false,
|
||||
error: { code: 'unauthorized', detail: `${kept.field_key} has to be entered again for a new address` },
|
||||
};
|
||||
}
|
||||
}
|
||||
const nextSecrets = sameTarget ? mergeSecrets(storedSecrets, input.credentials, secretKeys) : typedSecrets;
|
||||
const ownCredential = fields.every((f) => f.secret !== 1 || f.required !== 1 || !!typedSecrets[f.field_key]);
|
||||
const ownerId = existing && !ownCredential ? existing.owner_user_id : userId;
|
||||
|
||||
const settings: Record<string, string> = {};
|
||||
for (const key of plainKeys) {
|
||||
@@ -294,7 +325,7 @@ export class DocSyncConfigService {
|
||||
owner_user_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(urlCheck.data.url, encrypted, settingsJson, insecure, userId, existing.id);
|
||||
.run(urlCheck.data.url, encrypted, settingsJson, insecure, ownerId, existing.id);
|
||||
return this.getConnection(existing.id) as ConnectionRow;
|
||||
}
|
||||
const info = this.db.connection
|
||||
@@ -467,14 +498,27 @@ export class DocSyncConfigService {
|
||||
lastSyncError: link.last_sync_error,
|
||||
failureCount: link.failure_count,
|
||||
// Shown so a user can paste it into a provider that will not let TREK
|
||||
// subscribe on its own (Papra, and Nextcloud without admin rights).
|
||||
webhookUrl: webhookBaseUrl && link.webhook_token
|
||||
// subscribe on its own (Papra, and Nextcloud without admin rights). Not
|
||||
// for a provider that takes no webhook at all: an address with nowhere
|
||||
// to paste it only promises what the timer delivers anyway.
|
||||
webhookUrl: webhookBaseUrl && link.webhook_token && this.takesWebhook(link)
|
||||
? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}`
|
||||
: null,
|
||||
webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the store behind a binding can call TREK at all, as the last probe
|
||||
* recorded it. Unknown counts as yes: a connection that was never probed
|
||||
* still gets the address, and a stale answer costs nothing but a line.
|
||||
*/
|
||||
private takesWebhook(link: LinkRow): boolean {
|
||||
const recorded = this.getConnection(link.connection_id)?.capabilities;
|
||||
const caps = recorded ? safeParse(recorded) : null;
|
||||
return !(caps && typeof caps === 'object' && (caps as { push?: unknown }).push === 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a binding must not run. The sweep below marks it, but only while
|
||||
* Sync automatically is on, so a paused binding whose owner left is not
|
||||
|
||||
@@ -20,8 +20,9 @@ import { DocSyncService } from './doc-sync.service';
|
||||
*
|
||||
* `@Public` because a provider cannot hold a TREK session. The token in the URL
|
||||
* is the authentication, one per binding, so a leaked URL can only ever nudge
|
||||
* the one trip it belongs to, and nudging is all it can do. Where the provider
|
||||
* supports it, a shared secret is checked as well.
|
||||
* the one trip it belongs to, and nudging is all it can do. Where TREK
|
||||
* registered the subscription itself, the shared secret it handed over is
|
||||
* checked as well.
|
||||
*/
|
||||
@Controller('api/docsync/webhook')
|
||||
export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
@@ -78,7 +79,14 @@ export class DocSyncWebhookController implements OnModuleDestroy {
|
||||
if (!link || link.sync_enabled !== 1) return { received: true };
|
||||
if (!this.syncIsOn(link)) return { received: true };
|
||||
|
||||
const secret = this.config.webhookSecret(link);
|
||||
// The secret is only known to a provider TREK subscribed at itself, so it
|
||||
// is only demanded there. A URL pasted into a store by hand (Papra, or a
|
||||
// Nextcloud without admin rights) carries the token and nothing else: the
|
||||
// secret is never shown to anybody, and Papra signs with a secret of its
|
||||
// own that TREK cannot know. Demanding it there meant every such call was
|
||||
// dropped and the binding ran on the timer while the screen promised
|
||||
// instant updates.
|
||||
const secret = link.webhook_subscription_id ? this.config.webhookSecret(link) : '';
|
||||
if (secret && !this.secretMatches(req, secret)) return { received: true };
|
||||
|
||||
// Fire and forget. Paperless allows five seconds before it counts the call
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
Logger,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -28,6 +29,7 @@ import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
|
||||
import { DocSyncService } from './doc-sync.service';
|
||||
import { DocumentProviderRegistry } from './document-provider.registry';
|
||||
import { PROVIDER_DISABLED } from './doc-sync.constants';
|
||||
import { sameOrigin } from './doc-sync.helpers';
|
||||
import {
|
||||
DocsyncConnectionDto,
|
||||
DocsyncConnectionTestDto,
|
||||
@@ -62,6 +64,8 @@ import {
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.DOCUMENTS, 'Documents')
|
||||
export class DocSyncController {
|
||||
private readonly logger = new Logger(DocSyncController.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: DocSyncConfigService,
|
||||
private readonly sync: DocSyncService,
|
||||
@@ -205,7 +209,7 @@ export class DocSyncController {
|
||||
|
||||
@Delete('connections/:connectionId')
|
||||
@HttpCode(200)
|
||||
deleteConnection(
|
||||
async deleteConnection(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('connectionId') connectionId: string,
|
||||
@CurrentUser() user: User,
|
||||
@@ -213,8 +217,19 @@ export class DocSyncController {
|
||||
this.assertCanManage(tripId, user);
|
||||
const conn = this.config.getConnection(Number(connectionId));
|
||||
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
|
||||
// Its bindings go with it (ON DELETE CASCADE), so read them first.
|
||||
// Its bindings go with it (ON DELETE CASCADE), so read them first. Every
|
||||
// subscription TREK registered for one of them is taken down while the
|
||||
// credential is still here to do it with, as unbinding one does: left
|
||||
// standing, a Paperless workflow or a Nextcloud listener keeps posting to
|
||||
// a token that answers nothing, for good.
|
||||
const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id);
|
||||
const provider = this.registry.get(conn.provider_id);
|
||||
if (provider?.unregisterWebhook) {
|
||||
const ref = this.config.toRef(conn);
|
||||
for (const link of unbound) {
|
||||
if (link.webhook_subscription_id) await provider.unregisterWebhook(ref, link.webhook_subscription_id);
|
||||
}
|
||||
}
|
||||
this.config.deleteConnection(conn.id);
|
||||
for (const link of unbound) this.announceBinding(link);
|
||||
return { success: true };
|
||||
@@ -280,7 +295,8 @@ export class DocSyncController {
|
||||
|
||||
// Subscribe where the provider lets TREK do it itself. A failure here is
|
||||
// not a failure of the binding (polling still carries it), so it is logged
|
||||
// into the link state rather than thrown at the user.
|
||||
// rather than thrown at the user; the first run that follows would write
|
||||
// over anything put into the link state.
|
||||
const conn = this.config.getConnection(res.data.connection_id);
|
||||
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
|
||||
if (conn && provider?.registerWebhook) {
|
||||
@@ -292,7 +308,11 @@ export class DocSyncController {
|
||||
`${base}/api/docsync/webhook/${res.data.webhook_token}`,
|
||||
this.config.webhookSecret(res.data),
|
||||
);
|
||||
if (hook.success) {
|
||||
if (docFailed(hook)) {
|
||||
this.logger.warn(
|
||||
`link ${res.data.id}: ${conn.provider_id} refused the webhook subscription (${hook.error.code}${hook.error.detail ? `: ${hook.error.detail}` : ''}), polling carries it`,
|
||||
);
|
||||
} else {
|
||||
this.db.connection
|
||||
.prepare('UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?')
|
||||
.run(hook.data.subscriptionId, res.data.id);
|
||||
@@ -408,19 +428,6 @@ export class DocSyncController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Whether two URLs address the same server, for deciding if a stored secret may be reused. */
|
||||
function sameOrigin(a: string | null | undefined, b: string): boolean {
|
||||
if (!a) return false;
|
||||
try {
|
||||
const x = new URL(a);
|
||||
const y = new URL(b);
|
||||
return x.protocol === y.protocol && x.host === y.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a provider has to call back on.
|
||||
*
|
||||
|
||||
@@ -75,6 +75,22 @@ export function isAllowedByOperator(name: string, allowedCsv: string): boolean {
|
||||
.includes(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two URLs address the same server, for deciding if a stored secret
|
||||
* may be reused. Same scheme, same host, same port: a path may differ, because
|
||||
* the token still only ever reaches the server it was issued for.
|
||||
*/
|
||||
export function sameOrigin(a: string | null | undefined, b: string): boolean {
|
||||
if (!a) return false;
|
||||
try {
|
||||
const x = new URL(a);
|
||||
const y = new URL(b);
|
||||
return x.protocol === y.protocol && x.host === y.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The plan ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** What the core knows about one pairing before the run. */
|
||||
@@ -141,8 +157,13 @@ export type PlanAction =
|
||||
| { kind: 'conflict'; itemId: number; remote: RemoteDocument | null; local: LocalDocument | null }
|
||||
/** Present upstream last run, absent now. Recorded, never acted on. */
|
||||
| { kind: 'mark_remote_missing'; itemId: number }
|
||||
/** Deleted in TREK; what happens upstream is the link's delete policy. */
|
||||
| { kind: 'local_deleted'; itemId: number; remoteId: string }
|
||||
/**
|
||||
* Deleted in TREK; what happens upstream is the link's delete policy. With
|
||||
* `remoteGone`, the provider copy was already on record as missing and the
|
||||
* TREK copy has since been purged: the row closes, and no policy runs,
|
||||
* because there is nothing left on either side for it to act on.
|
||||
*/
|
||||
| { kind: 'local_deleted'; itemId: number; remoteId: string; remoteGone?: true }
|
||||
/**
|
||||
* Back out of TREK's trash while the provider copy stayed: the pairing counts
|
||||
* again. With `missing`, the copy is gone and TREK did not bin it, so the row
|
||||
@@ -414,10 +435,26 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
if (it.fileId !== null) itemsByFile.set(it.fileId, it);
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy on record as gone whose TREK copy has been purged since.
|
||||
*
|
||||
* The row was kept for a person to decide about the TREK copy, and they
|
||||
* did: it is out of the trash and cannot come back, so nothing is left to
|
||||
* decide and nothing to restore. Only a purge closes the row. A TREK copy
|
||||
* merely in the trash keeps it open, because binning the gap as TREK's own
|
||||
* doing would let a later restore upload what somebody else deleted.
|
||||
*/
|
||||
const goneOnBothSides = (it: SyncItemState): boolean =>
|
||||
it.state === 'remote_missing' && it.fileId === null && it.remoteId !== null;
|
||||
|
||||
// Upstream is unchanged: only what happened in TREK can need doing, and the
|
||||
// remote half of the plan would be reading a list the adapter did not fetch.
|
||||
if (input.remoteUnchanged) {
|
||||
for (const it of items) {
|
||||
if (goneOnBothSides(it)) {
|
||||
add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId, remoteGone: true });
|
||||
continue;
|
||||
}
|
||||
// A deletion already acted on stays acted on, as in the pair loop. A copy
|
||||
// on record as gone is absent from a full listing too, so a full run
|
||||
// plans nothing for it until it comes back; binning it here would also
|
||||
@@ -466,6 +503,21 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
r.contentHash !== null &&
|
||||
it.pushedSha256 !== null &&
|
||||
r.contentHash === it.pushedSha256;
|
||||
// The same reading for bytes both sides agreed on: a version marker moves
|
||||
// on a tag, a correspondent or a title as much as on a new revision, and
|
||||
// where the listing carries a hash it says which. Downloading the same
|
||||
// bytes again replaced the TREK file with a fresh row and sent the one
|
||||
// that carried the booking link to the trash, on every edit of metadata.
|
||||
// A row in error is left out: `touch` leaves that state alone, so it would
|
||||
// sit in error for good over bytes that are in step, while the download
|
||||
// it gets instead finds the same bytes and settles it without a new row.
|
||||
const sameBytes =
|
||||
remoteChanged &&
|
||||
r.contentHash !== null &&
|
||||
it.state !== 'error' &&
|
||||
it.contentSha256 !== null &&
|
||||
r.contentHash === it.contentSha256;
|
||||
const remoteBytesChanged = remoteChanged && !isEcho && !sameBytes;
|
||||
|
||||
const localChanged = !!l && l.sha256 !== null && it.contentSha256 !== null && l.sha256 !== it.contentSha256;
|
||||
|
||||
@@ -509,11 +561,11 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remoteChanged && !isEcho && localChanged) {
|
||||
if (remoteBytesChanged && localChanged) {
|
||||
settle(it, r, l ?? null);
|
||||
continue;
|
||||
}
|
||||
if (remoteChanged && !isEcho) {
|
||||
if (remoteBytesChanged) {
|
||||
if (direction === 'push') { add({ kind: 'touch', itemId: it.id, remote: r }); continue; }
|
||||
add({ kind: 'pull_update', remote: r, itemId: it.id });
|
||||
continue;
|
||||
@@ -538,11 +590,19 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
// would have given it: a Paperless title with a slash in it used to land
|
||||
// in the file manager as it was, and the next run read that as a rename
|
||||
// made in TREK and sent it upstream again.
|
||||
//
|
||||
// TREK's own name goes through it too. An upload keeps whatever name the
|
||||
// browser sent, and on Linux and macOS that may hold a colon or a question
|
||||
// mark, which a push stores as the agreed name. Compared raw against the
|
||||
// cleaned agreed name, such a file read as renamed in TREK on every run:
|
||||
// Paperless got the same title again each time, and on Nextcloud, whose
|
||||
// own cleaning differs, the row sat in a conflict that "keep TREK" could
|
||||
// never settle.
|
||||
const incoming = sanitizeIncomingName(r.name);
|
||||
if (l && incoming !== l.name) {
|
||||
const agreed = it.remoteName === null ? null : sanitizeIncomingName(it.remoteName);
|
||||
const providerRenamed = agreed !== null && incoming !== agreed;
|
||||
const localRenamed = agreed !== null && l.name !== agreed;
|
||||
const localRenamed = agreed !== null && sanitizeIncomingName(l.name) !== agreed;
|
||||
|
||||
if (providerRenamed && !localRenamed && direction !== 'push') {
|
||||
add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: incoming });
|
||||
@@ -559,6 +619,12 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
settleName(it, r, l, incoming);
|
||||
continue;
|
||||
}
|
||||
if (agreed !== null && !providerRenamed && !localRenamed) {
|
||||
// Neither side renamed: the two names differ only by what the cleaning
|
||||
// did to one of them, and there is nothing to send anywhere.
|
||||
add({ kind: 'touch', itemId: it.id, remote: r });
|
||||
continue;
|
||||
}
|
||||
// No stored name to arbitrate with (a row from before this column, or a
|
||||
// freshly paired document): follow the binding's direction.
|
||||
if (direction === 'pull') add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: incoming });
|
||||
@@ -570,6 +636,14 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
add({ kind: 'touch', itemId: it.id, remote: r });
|
||||
}
|
||||
|
||||
// ── Gone on both sides ────────────────────────────────────────────────────
|
||||
// A copy listed again went through the pair loop, where a purged file is a
|
||||
// deletion like any other.
|
||||
for (const it of items) {
|
||||
if (!goneOnBothSides(it) || remoteById.has(it.remoteId)) continue;
|
||||
add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId, remoteGone: true });
|
||||
}
|
||||
|
||||
// ── Upstream only ─────────────────────────────────────────────────────────
|
||||
if (direction !== 'push') {
|
||||
for (const r of remote) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { StorageService } from '../storage/storage.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { MAX_FILE_SIZE } from '../files/files.constants';
|
||||
import { MAX_FILE_SIZE, isVideoExtension } from '../files/files.constants';
|
||||
import { DocumentProviderRegistry } from './document-provider.registry';
|
||||
import { DocSyncConfigService, type ConnectionRow, type LinkRow } from './doc-sync-config.service';
|
||||
import type { DocumentProvider, RemoteDocument } from './document-provider';
|
||||
@@ -357,7 +357,10 @@ export class DocSyncService {
|
||||
return 'ok';
|
||||
}
|
||||
case 'local_deleted': {
|
||||
const binned = ctx.link.delete_policy === 'trash';
|
||||
// A copy already gone upstream cannot be binned, and asking would only
|
||||
// fail the run for a document that no longer exists anywhere.
|
||||
const remoteGone = action.remoteGone === true;
|
||||
const binned = ctx.link.delete_policy === 'trash' && !remoteGone;
|
||||
if (binned) {
|
||||
const res = await ctx.provider.trash(ctx.ref, ctx.scope, action.remoteId);
|
||||
if (docFailed(res)) return res.error.code;
|
||||
@@ -366,15 +369,18 @@ export class DocSyncService {
|
||||
// `local_deleted` row alone, so a policy changed later cannot reach
|
||||
// back. Whether TREK binned the copy is written down with it, because a
|
||||
// restore in TREK later has to know whether the gap upstream is TREK's.
|
||||
// A copy on record as gone stays on record: the holdings count reads
|
||||
// the mark, and the copy is no more at the store than it was before.
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
SET state = 'local_deleted', remote_missing_at = NULL,
|
||||
SET state = 'local_deleted',
|
||||
remote_missing_at = CASE WHEN ? = 1 THEN remote_missing_at ELSE NULL END,
|
||||
remote_trashed_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(binned ? 1 : 0, action.itemId);
|
||||
.run(remoteGone ? 1 : 0, binned ? 1 : 0, action.itemId);
|
||||
return 'ok';
|
||||
}
|
||||
case 'local_restored': {
|
||||
@@ -448,14 +454,18 @@ export class DocSyncService {
|
||||
remote: RemoteDocument,
|
||||
itemId: number | null,
|
||||
ctx: { provider: DocumentProvider; ref: ReturnType<DocSyncConfigService['toRef']>; scope: ReturnType<DocSyncConfigService['toScopeRef']>; link: LinkRow },
|
||||
): Promise<'pulled' | DocsyncErrorCode> {
|
||||
): Promise<'pulled' | 'ok' | DocsyncErrorCode> {
|
||||
const name = sanitizeIncomingName(remote.name);
|
||||
|
||||
// The same defences an upload goes through. A provider folder routinely
|
||||
// holds .svg and .html, and TREK serves downloads inline with a
|
||||
// Content-Type derived from the extension: letting those through would be
|
||||
// stored XSS. Rejected documents become a visible row, never a silent skip.
|
||||
if (isBlockedName(name) || !isAllowedByOperator(name, this.allowedTypes.get())) {
|
||||
// Video is admitted the way the upload admits it, whatever the operator's
|
||||
// list says: a clip the file manager takes must not come back as refused
|
||||
// when it arrives through the store instead.
|
||||
const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, this.allowedTypes.get());
|
||||
if (isBlockedName(name) || !allowed) {
|
||||
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
|
||||
return 'unsupported_type';
|
||||
}
|
||||
@@ -499,6 +509,36 @@ export class DocSyncService {
|
||||
}
|
||||
|
||||
const sha256 = hash.digest('hex');
|
||||
|
||||
// What this row pointed at before, if anything: a pull_update replaces a
|
||||
// document TREK already holds, and `createFile` only ever inserts.
|
||||
const pairing = itemId === null
|
||||
? undefined
|
||||
: (this.db.connection
|
||||
.prepare('SELECT file_id, remote_name, content_sha256 FROM document_sync_items WHERE id = ?')
|
||||
.get(itemId) as { file_id: number | null; remote_name: string | null; content_sha256: string | null } | undefined);
|
||||
const supersededId = pairing?.file_id ?? null;
|
||||
const superseded = supersededId === null ? undefined : this.files.getFileById(supersededId, ctx.link.trip_id);
|
||||
|
||||
// The bytes TREK already holds, under a version marker that moved on
|
||||
// metadata: Paperless bumps it for a tag or a correspondent as much as for
|
||||
// a new revision, and not every listing carries a hash the planner could
|
||||
// have read this off. Nothing to replace, so the file stays, and with it
|
||||
// the booking it hangs on. The agreed name stays too: a rename that came
|
||||
// with the edit is the planner's business next run, and moving the arbiter
|
||||
// here would read it as TREK's own and send the old name back up.
|
||||
if (superseded && !superseded.deleted_at && pairing?.content_sha256 === sha256) {
|
||||
await fs.promises.rm(tmpPath, { force: true });
|
||||
this.upsertItem(ctx.link, {
|
||||
itemId,
|
||||
remote,
|
||||
state: 'synced',
|
||||
remoteNameOverride: pairing.remote_name ?? undefined,
|
||||
errorCode: null,
|
||||
});
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.storage.put('files', storageKey, { tmpPath });
|
||||
} catch {
|
||||
@@ -507,14 +547,6 @@ export class DocSyncService {
|
||||
return 'provider_error';
|
||||
}
|
||||
|
||||
// What this row pointed at before, if anything: a pull_update replaces a
|
||||
// document TREK already holds, and `createFile` only ever inserts.
|
||||
const supersededId = itemId === null
|
||||
? null
|
||||
: (this.db.connection
|
||||
.prepare('SELECT file_id FROM document_sync_items WHERE id = ?')
|
||||
.get(itemId) as { file_id: number | null } | undefined)?.file_id ?? null;
|
||||
|
||||
/**
|
||||
* The file row, the retirement of the copy it replaces and the pairing are
|
||||
* one fact, so they are one transaction. Written separately, a crash
|
||||
@@ -524,7 +556,9 @@ export class DocSyncService {
|
||||
*
|
||||
* The superseded copy goes to the trash rather than out of existence: the
|
||||
* bytes it holds are a version somebody may still want, and TREK's own
|
||||
* delete works the same way.
|
||||
* delete works the same way. What it was attached to goes to the new row:
|
||||
* a new revision is the same document to the trip, and without this every
|
||||
* edit in the store quietly took the attachment off its booking.
|
||||
*/
|
||||
const { created, retired } = this.db.transaction(() => {
|
||||
const file = this.files.createFile(
|
||||
@@ -533,10 +567,24 @@ export class DocSyncService {
|
||||
// Attributed to the person whose connection brought it in, which is the
|
||||
// only honest answer: nobody in TREK uploaded it.
|
||||
this.config.getConnection(ctx.link.connection_id)?.owner_user_id ?? 0,
|
||||
{},
|
||||
{
|
||||
place_id: superseded?.place_id ?? null,
|
||||
reservation_id: superseded?.reservation_id ?? null,
|
||||
description: superseded?.description ?? null,
|
||||
},
|
||||
);
|
||||
const gone = supersededId !== null && Number(supersededId) !== Number(file.id);
|
||||
if (gone) this.files.softDeleteFile(supersededId as number);
|
||||
const starred = gone && !!superseded?.starred;
|
||||
if (gone) {
|
||||
if (starred) this.db.connection.prepare('UPDATE trip_files SET starred = 1 WHERE id = ?').run(file.id);
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO file_links (file_id, reservation_id, assignment_id, place_id, budget_item_id)
|
||||
SELECT ?, reservation_id, assignment_id, place_id, budget_item_id FROM file_links WHERE file_id = ?`,
|
||||
)
|
||||
.run(file.id, supersededId);
|
||||
this.files.softDeleteFile(supersededId as number);
|
||||
}
|
||||
this.upsertItem(ctx.link, {
|
||||
itemId,
|
||||
remote,
|
||||
@@ -545,7 +593,7 @@ export class DocSyncService {
|
||||
contentSha256: sha256,
|
||||
errorCode: null,
|
||||
});
|
||||
return { created: file, retired: gone ? (supersededId as number) : null };
|
||||
return { created: starred ? { ...file, starred: 1 } : file, retired: gone ? (supersededId as number) : null };
|
||||
});
|
||||
|
||||
// Announced only once it is committed: a rolled-back transaction that had
|
||||
@@ -1077,7 +1125,10 @@ export class DocSyncService {
|
||||
.prepare(
|
||||
`SELECT link_id,
|
||||
SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired,
|
||||
-- A deletion closed over a copy already gone keeps its missing mark,
|
||||
-- and that copy is at the store no more than a binned one is.
|
||||
SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' AND remote_trashed_at IS NULL
|
||||
AND (state != 'local_deleted' OR remote_missing_at IS NULL)
|
||||
THEN 1 ELSE 0 END) AS atProvider,
|
||||
SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing
|
||||
FROM document_sync_items
|
||||
|
||||
@@ -176,6 +176,15 @@ export function documentFileName(doc: PaperlessDocument): string {
|
||||
return base.toLowerCase().endsWith(extension) ? base : `${base}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The longest title Paperless stores. `Document.title` is a 128-character
|
||||
* column, and a PATCH with more is refused with a validation error that reaches
|
||||
* TREK as a bare provider_error, on every run, for as long as the name stays
|
||||
* that long. The consumer trims an upload's title on its own; the rename path
|
||||
* has to do it here.
|
||||
*/
|
||||
export const PAPERLESS_TITLE_MAX_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* The title for a file name. Only an extension this adapter would have ADDED is
|
||||
* removed, so a document genuinely called `Vertrag 2026.2` keeps its name.
|
||||
@@ -183,9 +192,16 @@ export function documentFileName(doc: PaperlessDocument): string {
|
||||
export function titleFromFileName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
const extension = extensionOf(trimmed);
|
||||
if (extension === null || !KNOWN_EXTENSIONS.has(extension)) return trimmed;
|
||||
const base = trimmed.slice(0, trimmed.length - extension.length).trim();
|
||||
return base.length > 0 ? base : trimmed;
|
||||
const base =
|
||||
extension === null || !KNOWN_EXTENSIONS.has(extension)
|
||||
? trimmed
|
||||
: trimmed.slice(0, trimmed.length - extension.length).trim();
|
||||
const title = base.length > 0 ? base : trimmed;
|
||||
// Counted in code points, the way Paperless counts them: a cut by UTF-16
|
||||
// units would split a surrogate pair and send a title Paperless cannot store.
|
||||
const glyphs = [...title];
|
||||
if (glyphs.length <= PAPERLESS_TITLE_MAX_LENGTH) return title;
|
||||
return glyphs.slice(0, PAPERLESS_TITLE_MAX_LENGTH).join('').trim();
|
||||
}
|
||||
|
||||
export function formatScopeKey(tagId: number): string {
|
||||
|
||||
@@ -88,6 +88,19 @@ const CERTIFICATE_CODES = new Set([
|
||||
]);
|
||||
const CERTIFICATE_CODE = /CERT|SELF_SIGNED|ERR_TLS/i;
|
||||
|
||||
/**
|
||||
* The guard's own wording for a name DNS could not resolve. It arrives as an
|
||||
* SsrfBlockedError like every other refusal, but nothing was blocked: there is
|
||||
* no address to block. Reporting it as a policy verdict sends the self-hoster
|
||||
* to the SSRF settings when the fault is a typo in the host name.
|
||||
*/
|
||||
const UNRESOLVED_HOST = /could not resolve hostname/i;
|
||||
|
||||
/** Whether a guard refusal is really a host that does not exist. */
|
||||
export function isUnresolvedHost(message: string): boolean {
|
||||
return UNRESOLVED_HOST.test(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* The codes every product answers alike. A client layers its own meaning on
|
||||
* top through `overrides`, and `fallback` answers whatever neither table knows.
|
||||
@@ -128,7 +141,9 @@ export function classifyTransportFailure(err: unknown): TransportFailure {
|
||||
.filter((message) => message.length > 0)
|
||||
.join(': ');
|
||||
|
||||
if (err instanceof SsrfBlockedError) return { code: 'ssrf_blocked', detail };
|
||||
if (err instanceof SsrfBlockedError) {
|
||||
return { code: isUnresolvedHost(err.message) ? 'unreachable' : 'ssrf_blocked', detail };
|
||||
}
|
||||
|
||||
const codes = chain.map(codeOf);
|
||||
const names = chain.map((entry) => (entry instanceof Error ? entry.name : ''));
|
||||
|
||||
@@ -426,6 +426,16 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
return docFail('provider_error', 'The resulting file path is not one DSM accepts');
|
||||
}
|
||||
|
||||
// Without a remoteId a name collision is a real conflict: the file at
|
||||
// that path is somebody else's, and overwriting it would destroy it
|
||||
// silently. The path is asked before a byte moves, because a successful
|
||||
// upload says nothing about a file that was already there, and the
|
||||
// checks below would read one as a transfer that arrived broken and
|
||||
// send the whole file again on every retry.
|
||||
if (req.remoteId === undefined && (await this.exists(creds, targetPath))) {
|
||||
return docFail('conflict', `${targetPath} already exists on the NAS`);
|
||||
}
|
||||
|
||||
await this.client.upload(creds, {
|
||||
folderPath,
|
||||
fileName,
|
||||
@@ -433,9 +443,9 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
size: req.size,
|
||||
mimeType: req.mimeType,
|
||||
mtimeSeconds: req.mtimeSeconds,
|
||||
// Replacing is only ever what the caller asked for. Without a remoteId a
|
||||
// name collision is a real conflict: the file at that path is somebody
|
||||
// else's, and overwriting it would destroy it silently.
|
||||
// Replacing is only ever what the caller asked for. For a new file the
|
||||
// flag stays off, so a namesake that appeared since the check above is
|
||||
// not overwritten either.
|
||||
overwrite: req.remoteId !== undefined,
|
||||
createParents: true,
|
||||
});
|
||||
@@ -517,16 +527,15 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
const path = this.requireInScope(scope, remoteId);
|
||||
const trashPath = joinSynoPath(scopePath, TRASH_FOLDER);
|
||||
await this.client.createFolder(creds, scopePath, TRASH_FOLDER);
|
||||
try {
|
||||
await this.client.move(creds, path, trashPath, { overwrite: false });
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof SynologyDriveError) || error.code !== 'conflict') throw error;
|
||||
if (!(await this.moveIntoBin(creds, path, trashPath))) {
|
||||
// A file of that name is already in the bin. Overwriting it would throw
|
||||
// away the older copy, which is the one thing a bin exists to prevent,
|
||||
// so the older copy steps aside under a timestamped name.
|
||||
const occupant = joinSynoPath(trashPath, baseName(path));
|
||||
await this.client.rename(creds, occupant, this.archivedName(baseName(path)));
|
||||
await this.client.move(creds, path, trashPath, { overwrite: false });
|
||||
if (!(await this.moveIntoBin(creds, path, trashPath))) {
|
||||
return docFail('conflict', `${path} is still in place after the bin made room for it`);
|
||||
}
|
||||
}
|
||||
return docOk(undefined);
|
||||
} catch (error: unknown) {
|
||||
@@ -534,6 +543,37 @@ export class SynologyDriveDocumentProvider implements DocumentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt to move a file into the bin without overwriting a namesake.
|
||||
*
|
||||
* False means something of that name is in the way. DSM can say so with a
|
||||
* conflict, but a task that finished is only its word that the task ran, not
|
||||
* that the file left its folder, so the source is asked afterwards: a file
|
||||
* that is still there was not moved, whatever the task reported. Reporting
|
||||
* success on its word alone would mark the document as binned in TREK while
|
||||
* every NAS user still sees it in the trip's folder.
|
||||
*/
|
||||
private async moveIntoBin(creds: SynologyDriveCreds, path: string, trashPath: string): Promise<boolean> {
|
||||
try {
|
||||
await this.client.move(creds, path, trashPath, { overwrite: false });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SynologyDriveError && error.code === 'conflict') return false;
|
||||
throw error;
|
||||
}
|
||||
return !(await this.exists(creds, path));
|
||||
}
|
||||
|
||||
/** Whether the NAS has an entry at that path. A folder counts: nothing can be uploaded over one. */
|
||||
private async exists(creds: SynologyDriveCreds, path: string): Promise<boolean> {
|
||||
try {
|
||||
await this.client.getInfo(creds, path);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SynologyDriveError && error.code === 'not_found') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** `boarding.pdf` → `boarding.1789745537.pdf`, keeping the extension readable. */
|
||||
private archivedName(name: string): string {
|
||||
const stamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -48,8 +48,20 @@ import { DOWNLOAD_MAX_BYTES, guardDownload, providerFetch, statusErrorCode } fro
|
||||
export type WebdavFlavor = 'nextcloud' | 'opencloud';
|
||||
|
||||
export interface WebdavCreds {
|
||||
/** Instance origin, already normalised: no trailing slash, no DAV suffix. */
|
||||
/**
|
||||
* Where the instance lives, already normalised: no trailing slash, no DAV
|
||||
* suffix. It carries the install's own path when there is one, so
|
||||
* `https://home.example.com/nextcloud` is as good a base as an origin, and
|
||||
* every request path is appended to it as is.
|
||||
*/
|
||||
origin: string;
|
||||
/**
|
||||
* The path part of `origin` (`/nextcloud`), absent for an install at the
|
||||
* root. The instance spells that prefix into every href it answers, and it
|
||||
* comes off again before the href is used as a request path, or the next
|
||||
* request would go to `/nextcloud/nextcloud/remote.php/...`.
|
||||
*/
|
||||
prefix?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
allowInsecureTls: boolean;
|
||||
@@ -79,7 +91,7 @@ export class WebdavError extends Error {
|
||||
|
||||
/** One `<d:response>` of a PROPFIND, reduced to what the sync core reads. */
|
||||
export interface WebdavEntry {
|
||||
/** Path portion of the `d:href`, still percent-encoded, as sent. */
|
||||
/** Path portion of the `d:href`, still percent-encoded, with the install's prefix taken off. */
|
||||
href: string;
|
||||
/** The same path decoded, for comparing against a requested path. */
|
||||
path: string;
|
||||
@@ -112,8 +124,8 @@ export interface WebdavDrive {
|
||||
* Path of `root.webDavUrl`, never its origin: the instance answers with its
|
||||
* own configured `OC_URL` (`https://localhost:8804` for a host reached as
|
||||
* 127.0.0.1), and following a host that an untrusted response chose is how an
|
||||
* SSRF guard gets walked around. Only the path survives, re-based on the
|
||||
* connection's own origin.
|
||||
* SSRF guard gets walked around. Only the path survives, minus the install's
|
||||
* prefix, re-based on the connection's own origin.
|
||||
*/
|
||||
webDavPath: string;
|
||||
}
|
||||
@@ -193,11 +205,15 @@ export function normalizeFileId(raw: string | null, flavor: WebdavFlavor): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* What a user pastes into the URL field, reduced to an origin.
|
||||
* What a user pastes into the URL field, reduced to the instance's base.
|
||||
*
|
||||
* People paste the address bar of their own file list, so the DAV and Graph
|
||||
* prefixes come off rather than turning into `/remote.php/dav/remote.php/dav`
|
||||
* 404s, which read like a wrong password.
|
||||
* People paste the address bar of their own file list, so the DAV, Graph and
|
||||
* web-app prefixes come off rather than turning into
|
||||
* `/remote.php/dav/remote.php/dav` 404s, which read like a wrong password.
|
||||
* What stays is the install's own path, if it has one: a Nextcloud under
|
||||
* `/nextcloud` is addressed there, not at the host's root. Only the two pages
|
||||
* a browser lands on are cut, the file list and the dashboard: a bare `/apps`
|
||||
* rule would also eat an install that lives under `/apps/nextcloud`.
|
||||
*/
|
||||
export function normalizeOrigin(baseUrl: string): string {
|
||||
return baseUrl
|
||||
@@ -205,11 +221,29 @@ export function normalizeOrigin(baseUrl: string): string {
|
||||
.replace(/[?#].*$/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/(?:remote|index)\.php(?:\/.*)?$/i, '')
|
||||
.replace(/\/apps\/(?:files|dashboard)(?:\/.*)?$/i, '')
|
||||
.replace(/\/dav\/spaces(?:\/.*)?$/i, '')
|
||||
.replace(/\/graph(?:\/.*)?$/i, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* A path the instance answered, minus the install's own prefix, so it can be
|
||||
* appended to `creds.origin` again. A path that does not start with the prefix
|
||||
* is left alone: an instance that answers hrefs relative to the host root
|
||||
* while being reached through a proxy path is not something to guess at.
|
||||
*/
|
||||
export function stripPrefix(path: string, prefix: string): string {
|
||||
if (!prefix) return path;
|
||||
if (path === prefix) return '/';
|
||||
return path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : path;
|
||||
}
|
||||
|
||||
/** The install's prefix on this connection, '' when it lives at the host root. */
|
||||
export function prefixOf(creds: WebdavCreds): string {
|
||||
return creds.prefix ?? '';
|
||||
}
|
||||
|
||||
/** Percent-encode each segment of a decoded path, keeping the separators. */
|
||||
export function encodePath(path: string): string {
|
||||
return path
|
||||
@@ -246,13 +280,17 @@ function hrefPathname(href: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** The still-encoded path of an href, which is what the next request is built from. */
|
||||
function hrefPath(href: string): string {
|
||||
return /^https?:\/\//i.test(href) ? hrefPathname(href) : href;
|
||||
/**
|
||||
* The still-encoded path of an href, which is what the next request is built
|
||||
* from: relative to the install, with the prefix the instance spelled into it
|
||||
* taken off again.
|
||||
*/
|
||||
function hrefPath(href: string, prefix: string): string {
|
||||
const path = /^https?:\/\//i.test(href) ? hrefPathname(href) : href;
|
||||
return stripPrefix(path, prefix);
|
||||
}
|
||||
|
||||
function decodeHrefPath(href: string): string {
|
||||
const raw = hrefPath(href);
|
||||
function decodePath(raw: string): string {
|
||||
try {
|
||||
return normalizePath(decodeURIComponent(raw));
|
||||
} catch {
|
||||
@@ -260,6 +298,10 @@ function decodeHrefPath(href: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHrefPath(href: string, prefix: string): string {
|
||||
return decodePath(hrefPath(href, prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* 405 is MKCOL on an existing collection, 412 a failed If-Match, 423 a locked
|
||||
* node: with 409, four ways of saying the caller's picture of the remote is
|
||||
@@ -348,12 +390,13 @@ function sha256Of(checksums: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function entryOf(response: Record<string, unknown>, flavor: WebdavFlavor): WebdavEntry | null {
|
||||
function entryOf(response: Record<string, unknown>, creds: WebdavCreds): WebdavEntry | null {
|
||||
const href = textOf(response.href);
|
||||
if (!href) return null;
|
||||
|
||||
const prefix = prefixOf(creds);
|
||||
const props = propsOf(response);
|
||||
const path = decodeHrefPath(href);
|
||||
const path = decodeHrefPath(href, prefix);
|
||||
const segments = path.split('/');
|
||||
const length = textOf(props.getcontentlength);
|
||||
const modified = textOf(props.getlastmodified);
|
||||
@@ -362,12 +405,12 @@ function entryOf(response: Record<string, unknown>, flavor: WebdavFlavor): Webda
|
||||
const resourceType = asRecord(props.resourcetype);
|
||||
|
||||
return {
|
||||
href: hrefPath(href),
|
||||
href: hrefPath(href, prefix),
|
||||
path,
|
||||
name: segments[segments.length - 1] ?? '',
|
||||
isCollection: resourceType !== null && 'collection' in resourceType,
|
||||
etag: textOf(props.getetag) ?? '',
|
||||
fileId: normalizeFileId(textOf(props.fileid), flavor),
|
||||
fileId: normalizeFileId(textOf(props.fileid), creds.flavor),
|
||||
size: length !== null && length !== '' && Number.isFinite(Number(length)) ? Number(length) : null,
|
||||
mimeType: mimeType ? mimeType.split(';')[0].trim() : null,
|
||||
lastModifiedIso:
|
||||
@@ -376,16 +419,17 @@ function entryOf(response: Record<string, unknown>, flavor: WebdavFlavor): Webda
|
||||
};
|
||||
}
|
||||
|
||||
function toDrive(value: unknown): WebdavDrive | null {
|
||||
function toDrive(value: unknown, prefix: string): WebdavDrive | null {
|
||||
const record = asRecord(value);
|
||||
const id = textOf(record?.id);
|
||||
if (!record || !id) return null;
|
||||
const webDavUrl = textOf(asRecord(record.root)?.webDavUrl);
|
||||
const webDavPath = webDavUrl ? davPathOf(webDavUrl) : null;
|
||||
return {
|
||||
id,
|
||||
name: textOf(record.name) ?? id,
|
||||
driveType: textOf(record.driveType) ?? '',
|
||||
webDavPath: (webDavUrl ? davPathOf(webDavUrl) : null) ?? `/dav/spaces/${encodeURIComponent(id)}`,
|
||||
webDavPath: webDavPath === null ? `/dav/spaces/${encodeURIComponent(id)}` : stripPrefix(webDavPath, prefix),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -469,7 +513,7 @@ export class WebdavClient {
|
||||
const entries: WebdavEntry[] = [];
|
||||
for (const element of asArray(multistatus.response)) {
|
||||
const record = asRecord(element);
|
||||
const entry = record ? entryOf(record, creds.flavor) : null;
|
||||
const entry = record ? entryOf(record, creds) : null;
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
@@ -478,8 +522,9 @@ export class WebdavClient {
|
||||
|
||||
// RFC 4918 puts the requested resource first, but it is matched by path
|
||||
// rather than trusted by position: the root must never end up in the
|
||||
// document list, and one misordered server would put it there.
|
||||
const wanted = decodeHrefPath(path);
|
||||
// document list, and one misordered server would put it there. The
|
||||
// requested path never carried the prefix, so it is only decoded.
|
||||
const wanted = decodePath(path);
|
||||
const selfIndex = Math.max(
|
||||
entries.findIndex((entry) => entry.path === wanted),
|
||||
0,
|
||||
@@ -670,7 +715,7 @@ export class WebdavClient {
|
||||
const parsed = asRecord(await this.graph(creds, 'GET', '/graph/v1.0/me/drives'));
|
||||
const drives: WebdavDrive[] = [];
|
||||
for (const value of asArray(parsed?.value)) {
|
||||
const drive = toDrive(value);
|
||||
const drive = toDrive(value, prefixOf(creds));
|
||||
if (drive) drives.push(drive);
|
||||
}
|
||||
return drives;
|
||||
@@ -679,7 +724,10 @@ export class WebdavClient {
|
||||
/** One space, or null when it is gone, which is what makes `scope_missing` detectable. */
|
||||
async getDrive(creds: WebdavCreds, driveId: string): Promise<WebdavDrive | null> {
|
||||
try {
|
||||
return toDrive(await this.graph(creds, 'GET', `/graph/v1.0/drives/${encodeURIComponent(driveId)}`));
|
||||
return toDrive(
|
||||
await this.graph(creds, 'GET', `/graph/v1.0/drives/${encodeURIComponent(driveId)}`),
|
||||
prefixOf(creds),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof WebdavError && err.status === 404) return null;
|
||||
throw err;
|
||||
@@ -689,6 +737,7 @@ export class WebdavClient {
|
||||
async createDrive(creds: WebdavCreds, name: string): Promise<WebdavDrive> {
|
||||
const drive = toDrive(
|
||||
await this.graph(creds, 'POST', '/graph/v1.0/drives', { name, driveType: 'project' }),
|
||||
prefixOf(creds),
|
||||
);
|
||||
if (!drive) {
|
||||
throw new WebdavError('provider_error', 'OpenCloud created a space it then described incompletely');
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
encodePath,
|
||||
normalizeOrigin,
|
||||
normalizePath,
|
||||
prefixOf,
|
||||
stripPrefix,
|
||||
type WebdavCreds,
|
||||
type WebdavEntry,
|
||||
type WebdavFlavor,
|
||||
@@ -113,10 +115,20 @@ export function flavorOf(conn: DocumentConnectionRef): WebdavFlavor | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable base: scheme and host, then the install's own path if it has one.
|
||||
* The second group is that path (`/nextcloud`), or '' for an install at the
|
||||
* root, which is what most Docker and snap setups are. A Nextcloud under a
|
||||
* sub-path is the ordinary package install, and refusing it here used to read
|
||||
* as "wrong password" on the settings screen.
|
||||
*/
|
||||
const BASE_URL = /^(https?:\/\/[^/]+)((?:\/[^/]+)*)$/i;
|
||||
|
||||
function credsOf(conn: DocumentConnectionRef): WebdavCreds | null {
|
||||
const flavor = flavorOf(conn);
|
||||
const origin = normalizeOrigin(conn.baseUrl);
|
||||
if (!flavor || !/^https?:\/\/[^/]+$/i.test(origin)) return null;
|
||||
const base = BASE_URL.exec(origin);
|
||||
if (!flavor || !base) return null;
|
||||
|
||||
const username =
|
||||
flavor === 'nextcloud'
|
||||
@@ -128,7 +140,7 @@ function credsOf(conn: DocumentConnectionRef): WebdavCreds | null {
|
||||
: textSetting(conn.secrets, 'app_token');
|
||||
if (!username || !password) return null;
|
||||
|
||||
return { origin, username, password, allowInsecureTls: conn.allowInsecureTls, flavor };
|
||||
return { origin, prefix: base[2], username, password, allowInsecureTls: conn.allowInsecureTls, flavor };
|
||||
}
|
||||
|
||||
/** `/remote.php/dav/files/<login>`: the per-account root of Nextcloud's file DAV. */
|
||||
@@ -759,9 +771,11 @@ export class WebdavDocumentProvider implements DocumentProvider {
|
||||
// The stored root is the space's own webDavUrl. Only its path is used, and
|
||||
// it is re-based on the connection's origin: the URL came out of a provider
|
||||
// response, and following the host in it would hand an attacker who can
|
||||
// answer for that instance a way past the SSRF guard.
|
||||
// answer for that instance a way past the SSRF guard. The origin already
|
||||
// carries the install's prefix, so the path is stored with it and loses it
|
||||
// here, or the request would spell it twice.
|
||||
const path = scope.remoteRootPath ? davPathOf(scope.remoteRootPath) : null;
|
||||
const rootPath = path ?? `/dav/spaces/${encodeURIComponent(scopeId)}`;
|
||||
const rootPath = path === null ? `/dav/spaces/${encodeURIComponent(scopeId)}` : stripPrefix(path, prefixOf(creds));
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = normalizePath(decodeURIComponent(rootPath));
|
||||
|
||||
@@ -156,6 +156,13 @@ export class AirtrailService {
|
||||
/**
|
||||
* "Test connection" from the settings form. Uses the typed URL/key when given;
|
||||
* falls back to the stored key when the key field still shows the mask.
|
||||
*
|
||||
* The stored key only for the origin it was stored against. The form prefills
|
||||
* the address and never the key, so retyping the address and pressing Test is
|
||||
* the ordinary way to move an instance, and a test button that carried the
|
||||
* stored key to whatever address was just typed would post the credential at
|
||||
* a stranger's server. A path edit on the same origin is the same instance
|
||||
* and keeps it.
|
||||
*/
|
||||
async testConnection(
|
||||
userId: number,
|
||||
@@ -168,7 +175,15 @@ export class AirtrailService {
|
||||
|
||||
const stored = this.getAirtrailCredentials(userId);
|
||||
const effectiveUrl = trimmedUrl || stored?.baseUrl;
|
||||
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
|
||||
const typedKey = provided && provided !== KEY_MASK ? provided : '';
|
||||
|
||||
if (!typedKey && stored && movedHost(stored.baseUrl, trimmedUrl)) {
|
||||
return {
|
||||
connected: false,
|
||||
error: `The stored key was issued for ${stored.baseUrl}. Enter the key for this address to test it.`,
|
||||
};
|
||||
}
|
||||
const effectiveKey = typedKey || stored?.apiKey;
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
return { connected: false, error: 'URL and API key required' };
|
||||
@@ -190,3 +205,21 @@ export class AirtrailService {
|
||||
return raw.map(normalizeFlight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the typed address point at a different AirTrail instance than the stored one?
|
||||
*
|
||||
* Scheme, host and port, because that is what decides where a credential is
|
||||
* sent. A path change ("/", "/api", a typo) is the same instance. Anything
|
||||
* unparseable is compared as written, which errs towards withholding the key
|
||||
* rather than shipping it somewhere new. An empty address is no move at all:
|
||||
* the stored one stands in for it.
|
||||
*/
|
||||
function movedHost(before: string | null, after: string): boolean {
|
||||
if (!before || !after) return false;
|
||||
try {
|
||||
return new URL(before).origin !== new URL(after).origin;
|
||||
} catch {
|
||||
return before !== after;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,11 +579,17 @@ export class DawarichSuggestionsService {
|
||||
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
|
||||
|
||||
const countries = await this.client.listVisitedCities(creds, from, to);
|
||||
const visited = new Set(
|
||||
this.db
|
||||
.all<{ country_code: string }>('SELECT country_code FROM visited_countries WHERE user_id = ?', userId)
|
||||
.map((r) => r.country_code.toUpperCase()),
|
||||
);
|
||||
|
||||
// "Already on the map" is what the Atlas itself paints as visited: the
|
||||
// countries a finished trip's places resolve to, a booking landed in, or a
|
||||
// hand mark added, minus the ones the user removed. Reading only the hand
|
||||
// marks would offer somebody their own past trips as news.
|
||||
const visited = new Set<string>();
|
||||
for (const country of (await this.atlas.stats(userId)).countries) {
|
||||
// The shape for a user without trips carries no status; everything in it
|
||||
// is a hand mark, which the Atlas draws as visited.
|
||||
if (!('status' in country) || country.status === 'visited') visited.add(country.code.toUpperCase());
|
||||
}
|
||||
|
||||
const resolved: DawarichAtlasCountry[] = [];
|
||||
const unresolved: string[] = [];
|
||||
|
||||
@@ -169,7 +169,7 @@ export class DawarichSyncService {
|
||||
if (!window) continue;
|
||||
|
||||
try {
|
||||
const result = await this.syncTripWindow(userId, trip.id, creds, window.from, window.to);
|
||||
const result = await this.syncTripWindow(userId, trip.id, creds, window.from, window.to, trips);
|
||||
created += result.created;
|
||||
updated += result.updated;
|
||||
missing += result.missing;
|
||||
@@ -234,6 +234,10 @@ export class DawarichSyncService {
|
||||
* it — so a changed-since filter, even if one existed, could never see a
|
||||
* deletion. Comparing the whole window against what TREK already holds is the
|
||||
* only thing that can.
|
||||
*
|
||||
* `trips` is every trip this run walks. A window reaches past its own trip
|
||||
* on both sides, so a stay can be fetched by two neighbouring trips, and the
|
||||
* one whose dates actually hold it is the one that gets it (see `tripForVisit`).
|
||||
*/
|
||||
async syncTripWindow(
|
||||
userId: number,
|
||||
@@ -241,6 +245,7 @@ export class DawarichSyncService {
|
||||
creds: DawarichCreds,
|
||||
from: Date,
|
||||
to: Date,
|
||||
trips: TripRow[] = [],
|
||||
): Promise<{ created: number; updated: number; missing: number }> {
|
||||
const { visits } = await this.client.listVisits(creds, from, to);
|
||||
|
||||
@@ -268,6 +273,8 @@ export class DawarichSyncService {
|
||||
visit.countryCodeFromSource ??
|
||||
(visit.lat !== null && visit.lng !== null ? getCountryFromCoords(visit.lat, visit.lng) : null);
|
||||
|
||||
const ownerTripId = tripForVisit(visit.localDate, tripId, existing?.trip_id ?? null, trips);
|
||||
|
||||
if (!existing) {
|
||||
this.db.run(
|
||||
`INSERT INTO dawarich_visit_suggestions
|
||||
@@ -277,7 +284,7 @@ export class DawarichSyncService {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)`,
|
||||
userId,
|
||||
visit.sourceVisitId,
|
||||
tripId,
|
||||
ownerTripId,
|
||||
visit.name,
|
||||
visit.lat,
|
||||
visit.lng,
|
||||
@@ -302,13 +309,15 @@ export class DawarichSyncService {
|
||||
|
||||
if (existing.state === 'new') {
|
||||
// Nobody has acted on it yet, so the newest version of the source is
|
||||
// simply the better suggestion. Overwriting here loses nothing.
|
||||
// simply the better suggestion. Overwriting here loses nothing, and
|
||||
// that includes the trip: a stay parked on a neighbour by an earlier
|
||||
// run moves to the trip whose dates hold it.
|
||||
this.db.run(
|
||||
`UPDATE dawarich_visit_suggestions
|
||||
SET name = ?, lat = ?, lng = ?, started_at = ?, ended_at = ?, duration_minutes = ?,
|
||||
local_date = ?, source_status = ?, confidence = ?, confidence_band = ?,
|
||||
country_code = ?, source_hash = ?, source_missing_at = NULL,
|
||||
trip_id = COALESCE(trip_id, ?), last_seen_at = ?
|
||||
trip_id = ?, last_seen_at = ?
|
||||
WHERE id = ?`,
|
||||
visit.name,
|
||||
visit.lat,
|
||||
@@ -322,7 +331,7 @@ export class DawarichSyncService {
|
||||
visit.confidenceBand,
|
||||
countryCode,
|
||||
hash,
|
||||
tripId,
|
||||
ownerTripId,
|
||||
new Date().toISOString(),
|
||||
existing.id,
|
||||
);
|
||||
@@ -520,6 +529,47 @@ interface TripRow {
|
||||
|
||||
interface SuggestionRow {
|
||||
id: number;
|
||||
trip_id: number | null;
|
||||
state: string;
|
||||
source_hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the trip's own span, not its widened window, hold this local date?
|
||||
*
|
||||
* Plain string order, because both sides are `YYYY-MM-DD`. A start that is not
|
||||
* a date at all covers nothing: `syncWindow` skips such a trip, and it must
|
||||
* not claim other trips' stays from the sidelines either.
|
||||
*/
|
||||
function tripCovers(trip: TripRow, localDate: string): boolean {
|
||||
if (!trip.start_date || !Number.isFinite(Date.parse(`${trip.start_date}T00:00:00Z`))) return false;
|
||||
if (localDate < trip.start_date) return false;
|
||||
return !trip.end_date || localDate <= trip.end_date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip a stay belongs to.
|
||||
*
|
||||
* Every window is padded by the lookback, so two trips a few days apart both
|
||||
* fetch the last days of the earlier one, and whichever asked first would
|
||||
* otherwise keep those stays for good. The trip whose dates hold the stay
|
||||
* outranks the trip whose padding merely reached it. A row already sitting on
|
||||
* a trip that covers its date stays put, so two overlapping trips cannot hand
|
||||
* a stay back and forth on every run. A stay outside every trip, such as the
|
||||
* evening before departure, keeps the trip it has or goes to the window that
|
||||
* found it, exactly as before.
|
||||
*/
|
||||
function tripForVisit(
|
||||
localDate: string,
|
||||
currentTripId: number,
|
||||
existingTripId: number | null,
|
||||
trips: TripRow[],
|
||||
): number {
|
||||
const existing = existingTripId === null ? undefined : trips.find((t) => t.id === existingTripId);
|
||||
if (existing && tripCovers(existing, localDate)) return existing.id;
|
||||
const current = trips.find((t) => t.id === currentTripId);
|
||||
if (current && tripCovers(current, localDate)) return current.id;
|
||||
const other = trips.find((t) => tripCovers(t, localDate));
|
||||
if (other) return other.id;
|
||||
return existingTripId ?? currentTripId;
|
||||
}
|
||||
|
||||
@@ -169,14 +169,22 @@ export class DawarichService {
|
||||
// instance's credential somewhere it was never issued for, so the key goes
|
||||
// with the host — and only with the host: correcting a typo in the path,
|
||||
// or adding a trailing slash, keeps it.
|
||||
if (newKey === undefined && movedHost(previousUrl, trimmedUrl)) {
|
||||
//
|
||||
// What the old instance answered goes with the host regardless of the
|
||||
// key: its probed capabilities and its sync history describe a server
|
||||
// this row no longer points at, and a fresh key does not make them true
|
||||
// of the new one.
|
||||
if (movedHost(previousUrl, trimmedUrl)) {
|
||||
this.db.run(
|
||||
`UPDATE dawarich_connections
|
||||
SET api_key = NULL, capabilities = NULL, last_sync_state = 'never',
|
||||
SET capabilities = NULL, last_sync_state = 'never',
|
||||
last_sync_error = NULL, last_sync_at = NULL
|
||||
WHERE user_id = ?`,
|
||||
userId,
|
||||
);
|
||||
if (newKey === undefined) {
|
||||
this.db.run('UPDATE dawarich_connections SET api_key = NULL WHERE user_id = ?', userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing the address leaves a key that can no longer be used for
|
||||
@@ -213,6 +221,11 @@ export class DawarichService {
|
||||
* Try the connection as it is about to be saved — with the typed key if there
|
||||
* is one, otherwise with the stored one.
|
||||
*
|
||||
* The stored one only for the host it was stored against. `saveSettings`
|
||||
* drops the key on a host change for a reason, and a test button that still
|
||||
* carried it to whatever address was just typed would post the credential
|
||||
* at a stranger's server before the save ever got the chance to refuse.
|
||||
*
|
||||
* Answers 200 with `connected: false` rather than throwing, because "wrong
|
||||
* key" is an answer the form has to render, not a server error.
|
||||
*/
|
||||
@@ -225,7 +238,16 @@ export class DawarichService {
|
||||
const typedKey = (apiKey || '').trim();
|
||||
const stored = this.getCredentials(userId);
|
||||
const baseUrl = (url || '').trim() || stored?.baseUrl || '';
|
||||
const key = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : stored?.apiKey;
|
||||
const typed = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : '';
|
||||
|
||||
if (!typed && stored && movedHost(stored.baseUrl, baseUrl)) {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'not_connected',
|
||||
errorDetail: `The stored key was issued for ${stored.baseUrl}. Enter the key for this address to test it.`,
|
||||
};
|
||||
}
|
||||
const key = typed || stored?.apiKey;
|
||||
|
||||
if (!baseUrl || !key) {
|
||||
return { connected: false, error: 'not_connected' };
|
||||
|
||||
@@ -740,8 +740,9 @@ export function parseWikipediaTag(tag: string | undefined | null): { lang: strin
|
||||
const NON_GOOGLE_PLACE_ID =
|
||||
/^(?:coords|gers|node|way|relation|amap):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
|
||||
// The subset that still has a provider behind it — Overpass for details,
|
||||
// Wikimedia for photos.
|
||||
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
|
||||
// Wikimedia for photos. The id has to be the whole of what follows the colon:
|
||||
// it is written into an Overpass query as it is, and an element id is a number.
|
||||
export const OSM_PLACE_ID = /^(?:node|way|relation):\d+$/i;
|
||||
|
||||
export function isGooglePlaceId(placeId: string): boolean {
|
||||
return !NON_GOOGLE_PLACE_ID.test(placeId);
|
||||
|
||||
@@ -495,6 +495,17 @@ async function flattenBrandLogo(bytes: Buffer): Promise<BrandLogo | null> {
|
||||
// day at the provider rather than a slow answer worth waiting for.
|
||||
const IDENTITY_TIMEOUT_MS = 2500;
|
||||
|
||||
// The explicit search asks Nominatim alongside the index, and the pair costs
|
||||
// the slower one. The index gives up after 3.5 s and the browser after 8 s, so a
|
||||
// Nominatim that accepts the connection and then sits on it (the public service
|
||||
// under load, a self-hosted one that hung) has to give up in between: without a
|
||||
// deadline of its own the request rides undici's 300 s default, and the index's
|
||||
// answer is thrown away with the request that timed out waiting for it. The
|
||||
// deadline starts after the throttle wait, so it measures the answer alone:
|
||||
// generous for a slow one, still inside the browser's budget with the wait
|
||||
// added on.
|
||||
const SEARCH_TIMEOUT_MS = 6000;
|
||||
|
||||
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
||||
let photoFetchActive = 0;
|
||||
const photoFetchQueue: Array<() => void> = [];
|
||||
@@ -1149,7 +1160,7 @@ export class MapsService {
|
||||
params.set('bounded', '0');
|
||||
}
|
||||
// Through the shared client: one throttle for the whole process.
|
||||
const response = await nominatimFetch('search', params, { lane });
|
||||
const response = await nominatimFetch('search', params, { lane, timeoutMs: SEARCH_TIMEOUT_MS });
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(
|
||||
@@ -1314,6 +1325,10 @@ export class MapsService {
|
||||
const typeMap: Record<string, string> = { node: 'node', way: 'way', relation: 'rel' };
|
||||
const oType = typeMap[osmType];
|
||||
if (!oType) return null;
|
||||
// The id is the one thing written into the query, and the query is a
|
||||
// language. An OSM element id is a number and nothing else; anything with
|
||||
// more in it is a statement of its own, sent under TREK's shared user agent.
|
||||
if (!/^\d+$/.test(osmId)) return null;
|
||||
const query = `[out:json][timeout:5];${oType}(${osmId});out tags;`;
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
@@ -2338,6 +2353,10 @@ export class MapsService {
|
||||
// colon branch below, which would otherwise read "gers" as an OSM type
|
||||
// and ask Overpass for an element that does not exist.
|
||||
if (placeId.startsWith('gers:')) {
|
||||
// The switch is a deployment property, and off means nothing leaves for
|
||||
// the index: a place saved while it was on is still opened from what the
|
||||
// trip holds, not looked up again.
|
||||
if (!this.trekPlacesEnabled()) return { place: null };
|
||||
const found = await trekPlacesById(placeId.slice('gers:'.length)).catch(() => null);
|
||||
if (!found) return { place: null };
|
||||
|
||||
@@ -2407,6 +2426,13 @@ export class MapsService {
|
||||
|
||||
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
||||
if (placeId.includes(':')) {
|
||||
// Only an element type with a numeric id is looked up. The id is written
|
||||
// into an Overpass query and a Nominatim lookup as it came in, and nothing
|
||||
// else with a colon in it (a legacy image URL, a coordinate pseudo-id) has
|
||||
// a details source: answering those with an empty record cost two
|
||||
// requests that could not succeed, and let anything after the colon be
|
||||
// sent as a query of its own.
|
||||
if (!OSM_PLACE_ID.test(placeId)) return { place: null };
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
// buildOsmDetails never yields name/address/coordinates — Nominatim is
|
||||
// always the source for those (Overpass contributes the tag-derived rest).
|
||||
|
||||
@@ -159,6 +159,14 @@ async function getJson<T>(
|
||||
params: Record<string, string | number | undefined>,
|
||||
opts: { maxBytes?: number; timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
// TREK_PLACES_ENABLED=false is the operator's word that nothing leaves for
|
||||
// the index. Every caller checks it before asking, and this is the one place
|
||||
// every request passes, so a caller that did not is still stopped here. Ahead
|
||||
// of the breaker: a refusal says nothing about reachability.
|
||||
if (!readEnv().maps.trekPlacesEnabled) {
|
||||
throw new Error('TREK Places API is switched off on this instance');
|
||||
}
|
||||
|
||||
const url = new URL(trekPlacesBaseUrl() + path);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||
|
||||
@@ -14,6 +14,7 @@ import { safeFetchFollow } from '../../utils/ssrfGuard';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import {
|
||||
MapsService,
|
||||
isGoogleMapsHost,
|
||||
readBrandIdentity,
|
||||
readWikiIdentity,
|
||||
withPhotoFetchSlot,
|
||||
@@ -182,6 +183,23 @@ function yesNo(value: unknown): 'yes' | 'no' | string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Google Maps link a provider record carries, or nothing.
|
||||
*
|
||||
* The record comes with the request, and the link goes out labelled "Google"
|
||||
* on a description every user of the instance reads from the cache. So it has
|
||||
* to be a Google Maps address by shape, over http(s), and anything else is
|
||||
* dropped rather than linked.
|
||||
*/
|
||||
function googleMapsLink(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !placeWebsiteSchema.safeParse(value).success) return null;
|
||||
try {
|
||||
return isGoogleMapsHost(new URL(value).hostname) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the place's OpenStreetMap tags into the short facts the column shows.
|
||||
*
|
||||
@@ -342,14 +360,25 @@ export class PlaceEnrichmentService {
|
||||
// while the same building in OpenStreetMap carried all of it. Each field is
|
||||
// taken from whichever source actually has it rather than from one source
|
||||
// for everything.
|
||||
const ownFacts = collectFacts(details);
|
||||
const result: CachedEnrichment = {
|
||||
photos,
|
||||
description,
|
||||
facts: mergeFacts(collectFacts(details), collectFacts(osmDetails)),
|
||||
facts: mergeFacts(ownFacts, collectFacts(osmDetails)),
|
||||
hours: collectHours(details) ?? collectHours(osmDetails),
|
||||
rating: collectRating(details) ?? collectRating(osmDetails),
|
||||
};
|
||||
this.writeCache(placeId, lang, result);
|
||||
// The cache is keyed by place and language, so a row written here is what
|
||||
// every user of the instance sees for a week. An OpenStreetMap summary and
|
||||
// a menu link are the two things read straight off the request's own
|
||||
// `details` that become a link on every screen, and when the request
|
||||
// carried those, the text and the link are the caller's word rather than
|
||||
// the map's: fine to answer them with, not to serve to everyone else. The
|
||||
// row simply is not written, and the next request computes its own answer.
|
||||
// A summary or a link the lookup here fetched itself is the map's and keeps.
|
||||
const fromCaller = req.details != null
|
||||
&& (description?.source === 'osm' || ownFacts.some((fact) => fact.url != null));
|
||||
if (!fromCaller) this.writeCache(placeId, lang, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -585,22 +614,21 @@ export class PlaceEnrichmentService {
|
||||
* The API fetches each page once, keeps the summary, and every instance reads
|
||||
* it from there. Only places carrying a GERS id can be looked up, which is
|
||||
* exactly the ones that came from the API in the first place.
|
||||
*
|
||||
* Asked of the index here, although the request's `details` carry the very
|
||||
* same field from the dialog's own lookup a moment earlier. The answer is
|
||||
* written into a cache the whole instance reads from for a week, and a
|
||||
* description with a link in it is the one thing in there that must not be
|
||||
* whatever the sender chose to put in the request body. One more short
|
||||
* lookup per place and week is the price of the text being the index's.
|
||||
*/
|
||||
private async websiteDescription(
|
||||
placeId: string,
|
||||
details?: Record<string, unknown> | null,
|
||||
): Promise<PlaceDescription | null> {
|
||||
private async websiteDescription(placeId: string): Promise<PlaceDescription | null> {
|
||||
if (!placeId.startsWith('gers:')) return null;
|
||||
// Off means nothing leaves for the index, a saved place included.
|
||||
if (!this.maps.trekPlacesEnabled()) return null;
|
||||
try {
|
||||
// The details lookup already fetched this place and now carries its
|
||||
// description, so the ordinary path costs nothing. Asking again meant two
|
||||
// full round trips to the index for one added place, each with its own
|
||||
// timeout budget, for a field the first answer already contained.
|
||||
const carried = (details as { description?: { text?: string; sourceUrl?: string } } | null | undefined)
|
||||
const got = (await trekPlacesById(placeId.slice(5)) as { description?: { text?: string; sourceUrl?: string } } | null)
|
||||
?.description;
|
||||
const got = carried
|
||||
?? (await trekPlacesById(placeId.slice(5)) as { description?: { text?: string; sourceUrl?: string } } | null)
|
||||
?.description;
|
||||
const text = typeof got?.text === 'string' ? got.text.trim() : '';
|
||||
if (!text) return null;
|
||||
// Through the same allow-list a place's website goes through: this
|
||||
@@ -662,7 +690,7 @@ export class PlaceEnrichmentService {
|
||||
// exists, and it is published in JSON-LD or og:description precisely so
|
||||
// machines can read it. That covers roughly 43 percent of places, where the
|
||||
// encyclopaedias cover a fraction of a percent.
|
||||
const fromSite = await this.websiteDescription(placeId, details);
|
||||
const fromSite = await this.websiteDescription(placeId);
|
||||
if (fromSite) return fromSite;
|
||||
|
||||
const apiKey = this.maps.getMapsKey(userId);
|
||||
@@ -672,7 +700,7 @@ export class PlaceEnrichmentService {
|
||||
return {
|
||||
text: summary,
|
||||
source: 'google',
|
||||
sourceUrl: typeof details?.google_maps_url === 'string' ? details.google_maps_url : null,
|
||||
sourceUrl: googleMapsLink(details?.google_maps_url),
|
||||
license: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -328,11 +328,16 @@ export class PlacesController {
|
||||
// A night booked at this place went with it, and took its partner booking and
|
||||
// that booking's expense along. Neither is covered by place:deleted, and an
|
||||
// expense linked by reservation_id is not one linkedExpenseIds finds.
|
||||
//
|
||||
// Sent to the deleting tab as well, on purpose. The socket id keeps a tab
|
||||
// from hearing back what it just did itself, and that tab only removed the
|
||||
// places: the booking and the expense went on the server alone, and a tab
|
||||
// that never hears about them keeps showing both until a reload.
|
||||
for (const reservationId of cancelled.reservationIds) {
|
||||
this.places.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
|
||||
this.places.broadcast(tripId, 'reservation:deleted', { reservationId }, undefined);
|
||||
}
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { deleted, count: deleted.length };
|
||||
}
|
||||
@@ -503,11 +508,12 @@ export class PlacesController {
|
||||
// A night booked at this place went with it, and took its partner booking and
|
||||
// that booking's expense along. Neither is covered by place:deleted, and an
|
||||
// expense linked by reservation_id is not one linkedExpenseIds finds.
|
||||
// Without a socket id, so the deleting tab hears it too: see bulkDelete.
|
||||
for (const reservationId of cancelled.reservationIds) {
|
||||
this.places.broadcast(tripId, 'reservation:deleted', { reservationId }, socketId);
|
||||
this.places.broadcast(tripId, 'reservation:deleted', { reservationId }, undefined);
|
||||
}
|
||||
for (const itemId of [...expenseIds, ...cancelled.budgetItemIds]) {
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
|
||||
this.places.broadcast(tripId, 'budget:deleted', { itemId }, undefined);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'delete_place',
|
||||
description: 'Delete a place from a trip.',
|
||||
description: 'Delete a place from a trip. Removes its day assignments, its linked expenses and any nights booked at it, including each night\'s reservation and that reservation\'s expense. Warn the user before calling this on a hotel with a booking: it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeId: z.number().int().positive(),
|
||||
@@ -413,7 +413,7 @@ export class PlacesMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_delete_places',
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well, plus each place\'s linked expenses and any nights booked at it, with their reservations and expenses. Warn the user before calling this: it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
|
||||
@@ -136,6 +136,12 @@ export class PublicApiService {
|
||||
* `order_index` decides the sequence and is then dropped: it is a storage detail
|
||||
* that only means something relative to its siblings, and an array already
|
||||
* carries order.
|
||||
*
|
||||
* A booked night puts a stop of its own on its check-in day, so the route can
|
||||
* reach the hotel. That stop is the booking, not a place the traveller planned
|
||||
* to visit, and the booking is already reported in full under `accommodations`;
|
||||
* listed here as well it would read as two different intentions. Same rule as
|
||||
* the shortlist below.
|
||||
*/
|
||||
private placesByDay(tripId: number): Map<number, PublicApiPlace[]> {
|
||||
const rows = this.db.all<PlaceRow>(
|
||||
@@ -147,6 +153,7 @@ export class PublicApiService {
|
||||
JOIN places p ON p.id = da.place_id
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.trip_id = ?
|
||||
AND da.accommodation_id IS NULL
|
||||
ORDER BY da.day_id ASC, da.order_index ASC`,
|
||||
tripId,
|
||||
);
|
||||
|
||||
@@ -38,7 +38,7 @@ export class ReservationsRpc {
|
||||
const input = parsed.data as Record<string, unknown>;
|
||||
this.requireValidEndpoints(input.endpoints);
|
||||
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
this.requireResolvableReferences(tripId, input);
|
||||
this.requireOwnReferences(tripId, input);
|
||||
const { reservation, accommodationCreated } = this.reservations.create(String(tripId), input as never);
|
||||
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
|
||||
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
|
||||
@@ -60,7 +60,7 @@ export class ReservationsRpc {
|
||||
this.guards.requireTripEdit(tripId, actor, RESERVATION_EDIT_ACTION);
|
||||
const current = this.reservations.getReservation(String(reservationId), String(tripId));
|
||||
if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
|
||||
this.requireResolvableReferences(tripId, input);
|
||||
this.requireOwnReferences(tripId, input);
|
||||
const { reservation, accommodationChanged } = this.reservations.update(String(reservationId), String(tripId), input as never, current as never);
|
||||
if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined);
|
||||
const cur = current as { title: string; type?: string };
|
||||
@@ -100,18 +100,19 @@ export class ReservationsRpc {
|
||||
}
|
||||
|
||||
/**
|
||||
* An id that resolves to nothing is a foreign-key error the plugin reads as a
|
||||
* crash (#2355), so it is named here instead. Only that case: a plugin has
|
||||
* always been able to point at another trip's day or place through this
|
||||
* surface, and taking that away is a decision for the plugin contract, not a
|
||||
* side effect of a bug fix — so the ids the REST route refuses for being
|
||||
* somebody else's are filtered back out.
|
||||
* The body's ids have to be this trip's, and they have to exist. reservation_edit
|
||||
* on tripId says the plugin may write here and nothing about day_id, place_id,
|
||||
* assignment_id, accommodation_id or the create_accommodation days it puts in the
|
||||
* body: a stay written against another trip's day puts a stop on that day, in a
|
||||
* plan the acting user may not even be able to read. The REST route and the MCP
|
||||
* tool refuse the same ids; this is the plugin half of that rule. An id that
|
||||
* resolves to nothing is a foreign-key error the plugin reads as a crash (#2355),
|
||||
* so it is named here as well, after the ownership check.
|
||||
*/
|
||||
private requireResolvableReferences(tripId: number, input: Record<string, unknown>): void {
|
||||
const elsewhere = new Set(this.reservations.referencesOutsideTrip(String(tripId), input as never));
|
||||
const unknown = this.reservations
|
||||
.unresolvedReferences(String(tripId), input as never)
|
||||
.filter((field) => !elsewhere.has(field));
|
||||
private requireOwnReferences(tripId: number, input: Record<string, unknown>): void {
|
||||
const offenders = this.reservations.referencesOutsideTrip(String(tripId), input as never);
|
||||
if (offenders.length > 0) throw new ForbiddenResource(`not part of trip ${tripId}: ${offenders.join(', ')}`);
|
||||
const unknown = this.reservations.unresolvedReferences(String(tripId), input as never);
|
||||
if (unknown.length > 0) throw new BadParams(`unknown reference: ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { ChargingService } from './charging.service';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
@McpController()
|
||||
export class ChargingMcp {
|
||||
@@ -16,7 +17,7 @@ export class ChargingMcp {
|
||||
annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async read({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) {
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
return ok(await this.charging.read(tripId, placeId));
|
||||
return answeringRefusals(async () => ok(await this.charging.read(tripId, placeId)));
|
||||
}
|
||||
@Tool({ name: 'lookup_roadtrip_charging_info', description: 'Read open charging availability and published tariffs for a charging station by coordinate, before it is added to a trip. Needs a trip only for access control. No API key required. Every caveat of get_roadtrip_charging_info applies unchanged: coverage varies by region and operator, unknown or stale status is not available capacity, prices are gross per-component figures that can carry conditions and extra fees and are not personalized roaming quotes. Report the source, licence and timestamp alongside any figure from it. Charging power, socket types and socket counts are not in this answer; they come from the place search.',
|
||||
inputSchema: { tripId: z.number().int().positive(), ...chargingLookupSchema.shape },
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { DayBoundariesService } from './day-boundaries.service';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
const when = addonGate(ADDON_IDS.ROADTRIP);
|
||||
|
||||
@@ -44,10 +45,13 @@ export class DayBoundariesMcp {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
|
||||
if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied();
|
||||
const boundaries = boundary
|
||||
? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber })
|
||||
: this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries });
|
||||
return ok({ boundaries });
|
||||
// A stop from another trip is refused by the service, with the reason.
|
||||
return answeringRefusals(() => {
|
||||
const boundaries = boundary
|
||||
? this.boundaries.save(tripId, { ...boundary, day_number: dayNumber })
|
||||
: this.boundaries.remove(tripId, dayNumber);
|
||||
this.realtime.broadcast(String(tripId), 'roadtripBoundary:changed', { boundaries });
|
||||
return ok({ boundaries });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { addonGate } from '../addons/addon-gate';
|
||||
import { GoogleRouteService } from './google-route.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { demoDenied } from '../../mcp/tools/_shared';
|
||||
import { answeringRefusals } from './roadtrip-mcp.helpers';
|
||||
|
||||
@McpController()
|
||||
export class GoogleRouteMcp {
|
||||
@@ -14,7 +15,7 @@ export class GoogleRouteMcp {
|
||||
@Tool({ name: 'preview_google_maps_route', description: 'Read ordered stops from a Google Maps directions link. No changes are saved. Review geocoded positions and unresolved stops before importing. The exact Google road geometry is not imported.',
|
||||
inputSchema: googleRoutePreviewRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
async preview({ url }: { url: string }) { return ok(await this.routes.preview(url)); }
|
||||
async preview({ url }: { url: string }) { return answeringRefusals(async () => ok(await this.routes.preview(url))); }
|
||||
|
||||
@Tool({ name: 'import_google_maps_route', description: 'Append reviewed Google Maps stops to an existing trip day in supplied order. Creates places and visits atomically. Requires place and day editing permissions. Existing visits remain. TREK calculates the road geometry; no Google route geometry is preserved.',
|
||||
inputSchema: { tripId: z.number().int().positive(), ...googleRouteImportSchema.shape },
|
||||
@@ -26,6 +27,6 @@ export class GoogleRouteMcp {
|
||||
access: { group: 'places', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) })
|
||||
import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
return ok(this.routes.import(input.tripId, ctx.userId, input));
|
||||
return answeringRefusals(() => ok(this.routes.import(input.tripId, ctx.userId, input)));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user