From 906eef43498efec8f086d3e63facdfbb359bc4c0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:34:34 +0200 Subject: [PATCH 1/7] feat(shared): add Dawarich contracts Zod schemas for the Dawarich integration: connection settings, the suggestion list and its accept payloads, the recorded-track window and the Atlas country/city proposals. Places gain an optional `source` so a place that came out of a recording can say so. --- shared/src/dawarich/dawarich.schema.ts | 431 +++++++++++++++++++++++++ shared/src/index.ts | 1 + shared/src/place/place.schema.ts | 6 + 3 files changed, 438 insertions(+) create mode 100644 shared/src/dawarich/dawarich.schema.ts diff --git a/shared/src/dawarich/dawarich.schema.ts b/shared/src/dawarich/dawarich.schema.ts new file mode 100644 index 000000000..612379e23 --- /dev/null +++ b/shared/src/dawarich/dawarich.schema.ts @@ -0,0 +1,431 @@ +import { z } from 'zod'; +import { idSchema } from '../common/primitives.schema'; + +/** + * Dawarich integration contracts (#2279, #214). + * + * Dawarich (github.com/Freika/dawarich) is a self-hosted location-history + * tracker. The split of duties is deliberate and settled: **Dawarich records, + * TREK plans and interprets.** TREK never starts a location tracker of its own, + * never writes into Dawarich, and never keeps a copy of the raw GPS archive — + * the track overlay is fetched for the window being looked at and thrown away + * again. + * + * The connection is per user (Settings → Integrations); the global on/off is + * the `dawarich` addon. Each user stores their own instance URL and API key, + * which reaches exactly that user's own recordings. + * + * Everything Dawarich produces arrives as a **suggestion owned by the person + * who connected the instance**. Nothing reaches a shared trip until they accept + * it, because a background poll that edits a trip three other people are + * planning is not a feature. + */ + +// ── Per-user connection ────────────────────────────────────────────────────── + +/** Placeholder returned instead of the stored key. Never the key itself. */ +export const DAWARICH_KEY_MASK = '••••••••'; + +export const dawarichSettingsSchema = z.object({ + /** Instance origin, e.g. https://dawarich.example.com — TREK appends /api/v1 itself. */ + url: z.string().trim().max(2048), + /** API key. Omitted, blank or the mask keeps the stored key unchanged. */ + apiKey: z.string().max(512).optional(), + /** Allow self-signed TLS certificates, which LAN instances routinely use. */ + allowInsecureTls: z.boolean().optional().default(false), + /** + * Poll for new visits in the background. Off means the user pulls by hand — + * some people would rather their trip planner not talk to their location + * history on a timer. + */ + syncEnabled: z.boolean().optional().default(true), +}); +export type DawarichSettings = z.infer; + +/** + * What the connected instance actually offers, probed once per connect and + * re-probed on every successful sync. + * + * This exists because three things were discussed with Dawarich's maintainer + * and none of them had shipped as of 1.14.4: an `updated_at` on a visit, a + * status filter on the visits endpoint, and an ISO-3166 country code on the + * visit's place. TREK works without all three — change detection falls back to + * a content hash, status filtering happens here, and the country is derived + * from the coordinates — and picks the better path up automatically if a later + * Dawarich version starts sending them. A missing capability is never a reason + * to refuse to connect. + */ +export const dawarichCapabilitiesSchema = z.object({ + /** `GET /api/v1/visits` answered. Without it there are no suggestions. */ + visits: z.boolean(), + /** `GET /api/v1/tracks` answered — pre-segmented lines, the good overlay source. */ + tracks: z.boolean(), + /** `GET /api/v1/points` answered — the fallback overlay source. */ + points: z.boolean(), + /** `GET /api/v1/locations` answered — used to tick off bucket-list entries. */ + locations: z.boolean(), + /** `GET /api/v1/countries/visited_cities` answered — feeds the Atlas. */ + visitedCities: z.boolean(), + /** A visit carries `updated_at`. As of 1.14.4 it does not. */ + visitUpdatedAt: z.boolean(), + /** A visit's place carries a country code. As of 1.14.4 it does not. */ + visitCountryCode: z.boolean(), + /** Reported by `X-Dawarich-Version`, so it is the instance's own word. */ + serverVersion: z.string().nullable(), + /** When this probe ran, ISO-8601. */ + probedAt: z.string(), +}); +export type DawarichCapabilities = z.infer; + +/** How the last sync attempt ended — what the connection card reports. */ +export const DAWARICH_SYNC_STATES = ['never', 'ok', 'partial', 'failed'] as const; +export const dawarichSyncStateSchema = z.enum(DAWARICH_SYNC_STATES); +export type DawarichSyncState = z.infer; + +export const dawarichConnectionSchema = z.object({ + url: z.string(), + apiKeyMasked: z.string(), + allowInsecureTls: z.boolean(), + syncEnabled: z.boolean(), + connected: z.boolean(), + /** ISO-8601 of the last sync that finished, successfully or not. */ + lastSyncAt: z.string().nullable(), + lastSyncState: dawarichSyncStateSchema, + /** + * Why the last sync did not fully succeed, already translated by the server + * into one of the reason codes below. Null when it did. + */ + lastSyncError: z.string().nullable(), + capabilities: dawarichCapabilitiesSchema.nullable(), +}); +export type DawarichConnection = z.infer; + +/** + * Why a Dawarich call failed, as a code rather than a sentence. + * + * The client renders these through i18n. A raw upstream message would reach the + * user in English on a German install, and some of them quote the URL back. + */ +export const DAWARICH_ERROR_CODES = [ + 'unreachable', + 'unauthorized', + 'forbidden', + 'not_found', + 'rate_limited', + 'server_error', + 'invalid_response', + 'too_large', + 'not_connected', + 'addon_disabled', +] as const; +export const dawarichErrorCodeSchema = z.enum(DAWARICH_ERROR_CODES); +export type DawarichErrorCode = z.infer; + +export const dawarichStatusSchema = z.object({ + connected: z.boolean(), + /** Visits found in the probe window, so the user sees the connection did something. */ + visitCount: z.number().optional(), + error: dawarichErrorCodeSchema.optional(), + /** Free-text detail for the cases where the code alone is not actionable. */ + errorDetail: z.string().optional(), + capabilities: dawarichCapabilitiesSchema.optional(), +}); +export type DawarichStatus = z.infer; + +// ── Visit suggestions ──────────────────────────────────────────────────────── + +/** + * What Dawarich thinks about a visit. Since 1.12.0 declining a visit **deletes** + * it, so only these two remain — and, crucially, an unconfirmed visit is + * displayed in Dawarich exactly like a confirmed one. Importing only + * `confirmed` would leave most instances looking empty, which is the failure + * mode the maintainer specifically warned about. + */ +export const DAWARICH_VISIT_STATUSES = ['suggested', 'confirmed'] as const; +export const dawarichVisitStatusSchema = z.enum(DAWARICH_VISIT_STATUSES); +export type DawarichVisitStatus = z.infer; + +/** Where a suggestion stands in TREK. Independent of what Dawarich thinks of it. */ +export const DAWARICH_SUGGESTION_STATES = ['new', 'accepted', 'dismissed'] as const; +export const dawarichSuggestionStateSchema = z.enum(DAWARICH_SUGGESTION_STATES); +export type DawarichSuggestionState = z.infer; + +/** What accepting a suggestion produced. */ +export const DAWARICH_SUGGESTION_TARGETS = ['place', 'journal', 'bucket_list'] as const; +export const dawarichSuggestionTargetSchema = z.enum(DAWARICH_SUGGESTION_TARGETS); +export type DawarichSuggestionTarget = z.infer; + +/** + * One recorded stay, as TREK holds it for review. + * + * `source_changed` and `source_missing` are the honest answer to a question the + * API cannot answer directly: Dawarich sends no `updated_at`, and deleting a + * visit removes it from the list rather than marking it. So a changed visit is + * detected by comparing a hash of the fields TREK shows, and a removed one by + * noticing it was absent from a full re-read of the same window. Neither ever + * rewrites something the user already accepted and possibly edited — they raise + * a flag and leave the decision where it belongs. + */ +export const dawarichSuggestionSchema = z.object({ + id: idSchema, + /** Dawarich's own visit id. The dedup key, together with the user. */ + sourceVisitId: z.string(), + /** The trip whose dates this stay fell into when it was first seen. */ + tripId: idSchema.nullable(), + tripTitle: z.string().nullable(), + name: z.string(), + lat: z.number().nullable(), + lng: z.number().nullable(), + /** ISO-8601 with offset, exactly as Dawarich reported it. */ + startedAt: z.string(), + endedAt: z.string(), + /** The stay's length in minutes, the number the review list sorts and filters on. */ + durationMinutes: z.number(), + /** + * The local calendar date this stay belongs to, resolved in the trip's own + * timezone. A stay that starts at 23:40 belongs to that evening, not to the + * next morning because UTC says so. + */ + localDate: z.string(), + sourceStatus: dawarichVisitStatusSchema, + /** + * Dawarich's own confidence in the detection, 0..100. Null when its detector + * reported none — older rows and manually created visits have none. + */ + confidence: z.number().nullable(), + /** + * The same number as a word: `high` (>= 70), `medium` (>= 40), `low`. + * Kept as a plain string rather than an enum, so a band a later Dawarich adds + * arrives intact instead of failing validation for the whole suggestion. + */ + confidenceBand: z.string().nullable(), + state: dawarichSuggestionStateSchema, + target: dawarichSuggestionTargetSchema.nullable(), + acceptedPlaceId: idSchema.nullable(), + acceptedJournalEntryId: idSchema.nullable(), + acceptedBucketListItemId: idSchema.nullable(), + /** The source row changed since this was accepted — shown, never auto-applied. */ + sourceChanged: z.boolean(), + /** The source row is gone from Dawarich — shown, never auto-deleted. */ + sourceMissing: z.boolean(), + /** A bucket-list entry this stay sits on top of, if one matched. */ + matchedBucketListItemId: idSchema.nullable(), + matchedBucketListName: z.string().nullable(), + /** ISO-3166-1 alpha-2, derived from the coordinates — Dawarich sends no code. */ + countryCode: z.string().nullable(), + firstSeenAt: z.string(), + lastSeenAt: z.string(), +}); +export type DawarichSuggestion = z.infer; + +export const dawarichSuggestionListSchema = z.object({ + suggestions: z.array(dawarichSuggestionSchema), + /** Repeated here so a panel can show provenance without a second request. */ + connected: z.boolean(), + lastSyncAt: z.string().nullable(), + lastSyncState: dawarichSyncStateSchema, + lastSyncError: z.string().nullable(), +}); +export type DawarichSuggestionList = z.infer; + +/** + * Accepting a suggestion. Every field the user could correct before saving is + * here, because the review step exists precisely so a detector's guess can be + * fixed rather than swallowed. + */ +export const dawarichAcceptSchema = z.object({ + target: dawarichSuggestionTargetSchema, + /** Required for `place`; optional context for `journal`. */ + tripId: idSchema.optional(), + /** Which day of the trip the place lands on. Omitted leaves it unplanned. */ + dayId: idSchema.optional(), + /** Required for `journal`. */ + journalId: idSchema.optional(), + /** Required for `bucket_list` — which wish this stay ticks off. */ + bucketListItemId: idSchema.optional(), + /** Corrected name. Falls back to the suggestion's own. */ + name: z.string().trim().min(1).max(255).optional(), + notes: z.string().max(5000).optional(), + lat: z.number().min(-90).max(90).optional(), + lng: z.number().min(-180).max(180).optional(), + /** `YYYY-MM-DD`. Falls back to the suggestion's resolved local date. */ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + /** `HH:MM`. Falls back to the recorded arrival and departure. */ + time: z.string().regex(/^\d{2}:\d{2}$/).optional(), + endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(), +}); +export type DawarichAccept = z.infer; + +export const dawarichAcceptResultSchema = z.object({ + suggestion: dawarichSuggestionSchema, + /** What was created, so the caller can navigate straight to it. */ + createdPlaceId: idSchema.nullable(), + createdJournalEntryId: idSchema.nullable(), + bucketListItemId: idSchema.nullable(), +}); +export type DawarichAcceptResult = z.infer; + +/** Putting a dismissed or accepted suggestion back into review. */ +export const dawarichSuggestionStateSchemaBody = z.object({ + state: z.enum(['new', 'dismissed']), +}); +export type DawarichSuggestionStateBody = z.infer; + +// ── Track overlay ──────────────────────────────────────────────────────────── + +/** + * How a segment was travelled, as Dawarich classified it. Passed through rather + * than mapped onto TREK's own transport modes: the two vocabularies do not line + * up (Dawarich has `stationary`, TREK has `ferry`), and inventing a mapping + * would put a confident wrong label on a recorded fact. + */ +export const dawarichTrackSegmentSchema = z.object({ + /** `[lat, lng]` pairs, matching how TREK stores every other track. */ + points: z.array(z.tuple([z.number(), z.number()])), + /** Dawarich's `dominant_mode`, or null on an instance too old to report one. */ + mode: z.string().nullable(), + startedAt: z.string(), + endedAt: z.string(), + distanceMeters: z.number().nullable(), +}); +export type DawarichTrackSegment = z.infer; + +/** One local calendar day of recorded movement. */ +export const dawarichTrackDaySchema = z.object({ + /** `YYYY-MM-DD` in the trip's timezone. */ + date: z.string(), + segments: z.array(dawarichTrackSegmentSchema), +}); +export type DawarichTrackDay = z.infer; + +/** + * The recorded route for a window, as fetched — not as stored. Nothing here is + * written to TREK's database; the next request asks Dawarich again. + */ +export const dawarichTrackSchema = z.object({ + days: z.array(dawarichTrackDaySchema), + /** + * Which endpoint answered. `tracks` is pre-segmented and cheap; `points` is + * the fallback for an instance that has not generated tracks yet, thinned + * before it leaves the server. + */ + source: z.enum(['tracks', 'points']), + /** ISO-8601 of the fetch, so the map can say how fresh the line is. */ + fetchedAt: z.string(), + /** Points after thinning, across all days. */ + pointCount: z.number(), + /** The window hit the page cap and the line is incomplete. Said out loud, never hidden. */ + truncated: z.boolean(), +}); +export type DawarichTrack = z.infer; + +// ── Atlas ──────────────────────────────────────────────────────────────────── + +/** + * A country Dawarich's recordings say the user was in, offered to the Atlas. + * + * Offered, not applied: the Atlas is a thing people curate, and a poll that + * quietly ticks off countries would overwrite a deliberate choice. Entries + * already marked by hand come back with `alreadyVisited` so they are shown as + * confirmation rather than as something new. + */ +export const dawarichAtlasCountrySchema = z.object({ + /** ISO-3166-1 alpha-2, resolved from Dawarich's country name. */ + countryCode: z.string(), + /** Dawarich's own spelling, kept so an unresolved country can still be named. */ + sourceName: z.string(), + cities: z.array( + z.object({ + name: z.string(), + /** How long the recordings put the user there, in minutes. */ + minutes: z.number(), + /** + * ISO-8601 of the most recent point in that city, or null when the + * instance sent none. Nullable rather than defaulted: a city stamped + * 1970-01-01 looks like data and is not. + */ + lastSeenAt: z.string().nullable(), + }), + ), + alreadyVisited: z.boolean(), +}); +export type DawarichAtlasCountry = z.infer; + +export const dawarichAtlasSuggestionsSchema = z.object({ + countries: z.array(dawarichAtlasCountrySchema), + /** Dawarich country names that matched no ISO code — named rather than dropped. */ + unresolved: z.array(z.string()), + fetchedAt: z.string(), +}); +export type DawarichAtlasSuggestions = z.infer; + +export const dawarichAtlasAcceptSchema = z.object({ + countryCodes: z.array(z.string().length(2)).min(1).max(300), +}); +export type DawarichAtlasAccept = z.infer; + +// ── Bucket list ────────────────────────────────────────────────────────────── + +/** + * Result of asking Dawarich whether the user ever reached a place on their + * bucket list. + * + * Proximity alone is not a visit — driving past a cathedral is not visiting it — + * so a match needs both a distance and a dwell time, and both are reported so + * the user can judge the call rather than trust it. + */ +export const dawarichBucketMatchSchema = z.object({ + itemId: idSchema, + name: z.string(), + /** Null when the recordings show nothing near it. */ + match: z + .object({ + /** ISO-8601 of the stay's start. */ + at: z.string(), + minutes: z.number(), + distanceMeters: z.number(), + /** How many recorded points backed the match. */ + points: z.number(), + }) + .nullable(), + /** Already ticked off before this scan ran. */ + alreadyVisited: z.boolean(), +}); +export type DawarichBucketMatch = z.infer; + +export const dawarichBucketScanSchema = z.object({ + matches: z.array(dawarichBucketMatchSchema), + /** Entries skipped because they carry no coordinates — nothing to match against. */ + skippedWithoutCoordinates: z.number(), + /** The scan stopped at the per-run cap; run it again for the rest. */ + truncated: z.boolean(), + fetchedAt: z.string(), +}); +export type DawarichBucketScan = z.infer; + +export const dawarichBucketConfirmSchema = z.object({ + itemIds: z.array(idSchema).min(1).max(200), + /** ISO-8601 the visit is recorded at. Defaults to the matched stay. */ + visitedAt: z.string().optional(), +}); +export type DawarichBucketConfirm = z.infer; + +// ── Tuning ─────────────────────────────────────────────────────────────────── + +/** + * What counts as being somewhere. + * + * These are TREK's numbers, not Dawarich's: Dawarich decides where a visit was, + * TREK decides whether that is enough to call a wish fulfilled. Kept next to the + * contract so the server, the tests and the help text cannot disagree. + */ +export const DAWARICH_BUCKET_MATCH_RADIUS_M = 250; +export const DAWARICH_BUCKET_MATCH_MIN_MINUTES = 20; +/** Bucket-list entries examined per scan — one upstream request each. */ +export const DAWARICH_BUCKET_SCAN_LIMIT = 50; +/** Points kept per day after thinning, so a month-long trip stays drawable. */ +export const DAWARICH_TRACK_POINTS_PER_DAY = 600; +/** How far back a sync looks for trips that have no end date yet. */ +export const DAWARICH_SYNC_LOOKBACK_DAYS = 3; +/** Days past a trip's end still worth polling — a stay can end after midnight. */ +export const DAWARICH_SYNC_LOOKAHEAD_DAYS = 1; diff --git a/shared/src/index.ts b/shared/src/index.ts index 89a3f15c8..8fd5ad281 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -33,6 +33,7 @@ export * from './reservation/reservation.schema'; export * from './reservation/ki-reservation.schema'; export * from './datetime/datetime-normalize'; export * from './airtrail/airtrail.schema'; +export * from './dawarich/dawarich.schema'; export * from './day/day.schema'; export * from './day/note-colors'; export * from './assignment/assignment.schema'; diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 43db81962..e202daeb6 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -138,6 +138,12 @@ export const placeSchema = z.object({ // The Amap (高德) POI id a place was found by, `amap:`-prefixed. null for every // place that came from another provider. amap_poi_id: z.string().nullable().optional(), + /** + * Where the place came from when it was not typed by a person — currently + * only `'dawarich'`, for a stay accepted out of someone's own recordings. + * null on every place added by hand, which is nearly all of them. + */ + source: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(), // Manual track colour (#776). null = inherit the category colour like before. route_color: hexColorSchema.nullable().optional(), From dad13161724136089cb18bdcdff250c9534ce43b Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:34:42 +0200 Subject: [PATCH 2/7] feat(api): let public API keys be scoped to single areas A key could only ever read everything a user owns. When a second service reads TREK on that user's behalf, that is more than it needs: Dawarich wants trips, days and places, not notes, bookings or the wish list. Keys can now be minted with an explicit list of areas, checked in the guard and shown when the key is listed. Keys that existed before this keep full read access, so nothing that works today stops working. A key marked as limited whose list cannot be read grants nothing rather than everything. --- client/src/api/client.ts | 6 +- .../components/Settings/ApiKeysSection.tsx | 66 +- server/src/nest/public-api/api-token.guard.ts | 23 +- .../src/nest/public-api/public-api-request.ts | 55 ++ .../nest/public-api/public-api.controller.ts | 23 +- server/src/nest/tokens/token.service.ts | 131 +++- server/tests/e2e/public-api.e2e.test.ts | 231 +++++- .../tests/unit/nest/public-api-scopes.test.ts | 681 ++++++++++++++++++ .../unit/nest/public-api.controller.test.ts | 14 +- .../tests/unit/nest/public-api.guard.test.ts | 20 +- shared/src/i18n/ar/settings.ts | 15 + shared/src/i18n/br/settings.ts | 15 + shared/src/i18n/ca/settings.ts | 15 + shared/src/i18n/cs/settings.ts | 15 + shared/src/i18n/de/settings.ts | 15 + shared/src/i18n/en/settings.ts | 15 + shared/src/i18n/es/settings.ts | 15 + shared/src/i18n/fr/settings.ts | 15 + shared/src/i18n/gr/settings.ts | 15 + shared/src/i18n/hu/settings.ts | 15 + shared/src/i18n/id/settings.ts | 15 + shared/src/i18n/it/settings.ts | 15 + shared/src/i18n/ja/settings.ts | 15 + shared/src/i18n/ko/settings.ts | 15 + shared/src/i18n/nl/settings.ts | 15 + shared/src/i18n/pl/settings.ts | 15 + shared/src/i18n/ru/settings.ts | 15 + shared/src/i18n/sv/settings.ts | 15 + shared/src/i18n/tr/settings.ts | 15 + shared/src/i18n/uk/settings.ts | 15 + shared/src/i18n/vi/settings.ts | 15 + shared/src/i18n/zh-TW/settings.ts | 15 + shared/src/i18n/zh/settings.ts | 15 + shared/src/public-api/public-api.schema.ts | 76 ++ wiki/Public-API.md | 21 +- 35 files changed, 1657 insertions(+), 35 deletions(-) create mode 100644 server/tests/unit/nest/public-api-scopes.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 8313aaee9..595451215 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -21,6 +21,7 @@ import { type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, type ResetPasswordRequest, type ChangePasswordRequest, type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, + type ApiTokenCreateRequest, type PublicApiScope, type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, @@ -342,7 +343,10 @@ export const authApi = { // and a key of the wrong kind is refused like one that does not exist. apiKeys: { list: () => apiClient.get('/auth/api-tokens').then(r => r.data), - create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), + // `scopes` narrows what the key may read (#2279). Omitted means everything, + // which is what every key minted before scopes existed still does. + create: (name: string, scopes?: PublicApiScope[]) => + apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data), }, passkey: { diff --git a/client/src/components/Settings/ApiKeysSection.tsx b/client/src/components/Settings/ApiKeysSection.tsx index b33be69f2..4c0659507 100644 --- a/client/src/components/Settings/ApiKeysSection.tsx +++ b/client/src/components/Settings/ApiKeysSection.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react' import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react' +import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared' import Section from './Section' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' @@ -24,6 +25,9 @@ interface ApiKey { token_prefix: string created_at: string last_used_at: string | null + /** 'all' for every key minted before scopes existed, and for any key created without narrowing. */ + scope_mode?: 'all' | 'limited' + scopes?: PublicApiScope[] } export default function ApiKeysSection(): React.ReactElement { @@ -37,6 +41,12 @@ export default function ApiKeysSection(): React.ReactElement { const [created, setCreated] = useState(null) const [deleteId, setDeleteId] = useState(null) const [copied, setCopied] = useState(false) + /** + * What the new key may read. Starts as everything, because that is what a key + * did before this existed and what most integrations want — narrowing is a + * deliberate act, and the dialog should not make the common case work harder. + */ + const [newScopes, setNewScopes] = useState>(new Set(PUBLIC_API_SCOPES)) useEffect(() => { authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {}) @@ -46,10 +56,23 @@ export default function ApiKeysSection(): React.ReactElement { if (!newName.trim() || creating) return setCreating(true) try { - const d = await authApi.apiKeys.create(newName.trim()) + // All of them selected means "no narrowing", which is what the server + // stores as `all` — sending the full list would record it as a limited key + // that happens to allow everything, and a section added in a later version + // would then be refused for a key nobody meant to restrict. + const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined + const d = await authApi.apiKeys.create(newName.trim(), narrowed) setCreated(d.token.raw_token) setKeys(prev => [ - { id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null }, + { + id: d.token.id, + name: d.token.name, + token_prefix: d.token.token_prefix, + created_at: d.token.created_at, + last_used_at: null, + scope_mode: d.token.scope_mode, + scopes: d.token.scopes, + }, ...prev, ]) setNewName('') @@ -83,6 +106,16 @@ export default function ApiKeysSection(): React.ReactElement { setModalOpen(false) setCreated(null) setNewName('') + setNewScopes(new Set(PUBLIC_API_SCOPES)) + } + + const toggleScope = (scope: PublicApiScope) => { + setNewScopes(prev => { + const next = new Set(prev) + if (next.has(scope)) next.delete(scope) + else next.add(scope) + return next + }) } return ( @@ -114,6 +147,14 @@ export default function ApiKeysSection(): React.ReactElement { · {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)} )}

+ {/* What the key may read. Shown on the row rather than behind a + detail view: the whole reason to narrow a key is to be able + to see later that you did. */} +

+ {key.scope_mode === 'limited' && key.scopes + ? key.scopes.map(scope => t(`settings.apiScopes.${scope}`)).join(', ') + : t('settings.apiScopes.all')} +

- diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts index 3e2d7e642..e173b0ff1 100644 --- a/server/src/nest/public-api/api-token.guard.ts +++ b/server/src/nest/public-api/api-token.guard.ts @@ -21,13 +21,17 @@ import { TokenService } from '../tokens/token.service'; * - **No session JWT.** A session cookie leaking into a third-party integration is * exactly what a machine credential exists to prevent, and a JWT that reaches * this surface is almost always an accident. - * - **No OAuth tokens (yet).** They carry scopes this surface does not interpret - * yet, and accepting a credential whose restrictions you ignore is worse than - * refusing it. When `/api/v1` grows write routes, scopes get honoured first. + * - **No OAuth tokens.** They carry MCP scopes, which are a different vocabulary + * from the read sections this surface understands — `trips:write` has no + * meaning on a read-only API, and quietly mapping one onto the other would be + * inventing permissions nobody granted. Accepting a credential whose + * restrictions you reinterpret is worse than refusing it. * - * The guard authenticates but does not authorise: it resolves `req.user` and stops - * there. Which trips that user may read is decided per row against - * `DatabaseService.canAccessTrip`, never from anything the caller sent. + * The guard resolves two things and then stops: **who** is calling (`req.user`) + * and **what their key may read** (`req.apiToken`). Both are needed and neither + * is sufficient. Which trips that user may reach is still decided per row + * against `DatabaseService.canAccessTrip`, never from anything the caller sent; + * the grant only narrows that further, and can never widen it. */ @Injectable() export class ApiTokenGuard implements CanActivate { @@ -42,14 +46,15 @@ export class ApiTokenGuard implements CanActivate { 401, ); } - const user = this.tokens.verifyApiToken(token); - if (!user) { + const resolved = this.tokens.verifyApiTokenWithGrant(token); + if (!resolved) { throw new HttpException( { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, 401, ); } - req.user = user; + req.user = resolved.user; + req.apiToken = resolved.grant; return true; } } diff --git a/server/src/nest/public-api/public-api-request.ts b/server/src/nest/public-api/public-api-request.ts index 0e9340888..dbed53680 100644 --- a/server/src/nest/public-api/public-api-request.ts +++ b/server/src/nest/public-api/public-api-request.ts @@ -1,5 +1,6 @@ import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; +import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'; import type { RateLimitService } from '../common/rate-limit.service'; /** @@ -44,3 +45,57 @@ export function requireUserId(req: Request): number { } return id; } + +/** + * What the key on this request may read. + * + * A request that somehow reached a route without the guard has no grant, and + * the honest answer there is "nothing" rather than "everything" — the same + * fail-closed reasoning as `requireUserId` throwing a 401. A key with no + * narrowing has already been resolved to the full list by the token service, + * so there is no absent-means-all rule left to get wrong here. + */ +export function grantedScopes(req: Request): readonly PublicApiScope[] { + return req.apiToken?.scopes ?? []; +} + +/** + * Refuse a request whose key does not cover the section it asked for. + * + * **403, and a code of its own.** Not 401: the credential is valid and + * re-authenticating will not help. Not 404: an integrator debugging a key they + * narrowed themselves needs to be told that, not sent looking for a missing + * trip. The two existing codes are deliberately left alone so + * "expired/invalid" and "not enough access" stay distinguishable. + */ +export function requireScope(req: Request, scope: PublicApiScope): void { + if (!grantedScopes(req).includes(scope)) { + throw new HttpException( + { + error: `This API key is not allowed to read ${scope}`, + code: 'API_SCOPE_FORBIDDEN', + required_scope: scope, + }, + 403, + ); + } +} + +/** + * Narrow a set of requested sections to what the key may actually read. + * + * Filtering rather than refusing, for one specific case: `include` defaults to + * everything, so a narrow key asking for a trip with no `include` at all would + * otherwise be refused for wanting sections it never named. It gets what it may + * have. A caller that *names* a forbidden section is a different matter and is + * refused by `requireScope` at the controller, because silently dropping what + * somebody explicitly asked for is how integrators end up debugging their own + * correct code. + */ +export function narrowToGrant(sections: readonly T[], req: Request): T[] { + const granted = new Set(grantedScopes(req)); + return sections.filter((section) => granted.has(section)); +} + +/** Every section, for the callers that need the canonical list. */ +export const ALL_PUBLIC_API_SCOPES: readonly PublicApiScope[] = PUBLIC_API_SCOPES; diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts index 5d1c0a61c..48775b9af 100644 --- a/server/src/nest/public-api/public-api.controller.ts +++ b/server/src/nest/public-api/public-api.controller.ts @@ -10,7 +10,12 @@ import { } from '@trek/shared'; import { ApiTokenGuard } from './api-token.guard'; import { PublicApiService } from './public-api.service'; -import { enforcePublicApiRateLimit, requireUserId } from './public-api-request'; +import { + enforcePublicApiRateLimit, + narrowToGrant, + requireScope, + requireUserId, +} from './public-api-request'; import { RateLimitService } from '../common/rate-limit.service'; /** @@ -48,6 +53,7 @@ export class PublicApiController { @Get('trips') listTrips(@Req() req: Request): PublicApiTripList { this.limit(req); + requireScope(req, 'trips'); return { trips: this.api.listTrips(requireUserId(req)) }; } @@ -61,6 +67,7 @@ export class PublicApiController { @Get('bucket-list') listBucketList(@Req() req: Request): PublicApiBucketList { this.limit(req); + requireScope(req, 'bucket-list'); return { items: this.api.listBucketList(requireUserId(req)) }; } @@ -78,8 +85,20 @@ export class PublicApiController { @Query('include') include?: string, ): PublicApiTrip { this.limit(req); + requireScope(req, 'trips'); const tripId = parseTripId(id); - const trip = this.api.getTrip(tripId, requireUserId(req), parseInclude(include)); + + // A named section the key may not read is refused outright; an unnamed one — + // `include` absent, which means "everything" — is simply narrowed. Silently + // dropping a section somebody asked for by name is how an integrator ends + // up debugging their own correct code. + const asked = parseInclude(include); + if (include !== undefined && include.trim() !== '') { + for (const section of asked) requireScope(req, section); + } + const allowed = narrowToGrant(asked, req); + + const trip = this.api.getTrip(tripId, requireUserId(req), allowed); if (!trip) { throw new HttpException({ error: 'Trip not found' }, 404); } diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts index a5df4ca80..294494c2c 100644 --- a/server/src/nest/tokens/token.service.ts +++ b/server/src/nest/tokens/token.service.ts @@ -1,5 +1,10 @@ import { Injectable } from '@nestjs/common'; import { randomBytes, createHash } from 'crypto'; +import { + PUBLIC_API_SCOPES, + type PublicApiGrant, + type PublicApiScope, +} from '@trek/shared'; import { DatabaseService } from '../database/database.service'; import { EphemeralTokenService } from '../auth/ephemeral-token.service'; // Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls @@ -57,21 +62,46 @@ export class TokenService { } private listTokens(userId: number, kind: TokenKind) { - return this.db.all( - 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC', + const rows = this.db.all( + 'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC', userId, kind ); + // The MCP list keeps the exact shape it has always had: those tokens carry + // no read scopes, and two columns that are always "everything" would be + // noise in a panel that cannot act on them. + if (kind === 'mcp') { + return rows.map(({ scope_mode: _mode, api_scopes: _scopes, ...rest }) => rest); + } + return rows.map((row) => { + const grant = resolveGrant(row.scope_mode, row.api_scopes); + return { + id: row.id, + name: row.name, + token_prefix: row.token_prefix, + created_at: row.created_at, + last_used_at: row.last_used_at, + scope_mode: grant.mode, + scopes: grant.scopes, + }; + }); } createMcpToken(userId: number, rawName: unknown) { return this.createToken(userId, rawName, 'mcp'); } - createApiToken(userId: number, rawName: unknown) { - return this.createToken(userId, rawName, 'api'); + /** + * An integration key, optionally narrowed to a few sections. + * + * Omitting `scopes` means the key reads everything — what every key did + * before this argument existed. Narrowing is opt-in on purpose: shipping the + * column must not change what an already-running integration can do. + */ + createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) { + return this.createToken(userId, rawName, 'api', scopes); } - private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record } { + private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { error?: string; status?: number; token?: Record } { const name = rawName as string | undefined; if (!name?.trim()) return { error: 'Token name is required', status: 400 }; if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 }; @@ -83,9 +113,15 @@ export class TokenService { const tokenHash = createHash('sha256').update(rawToken).digest('hex'); const tokenPrefix = rawToken.slice(0, 13); + // An MCP token never carries read scopes, so it is stored as "everything" — + // the column default — rather than being handed a list it would ignore. + const narrowed = kind === 'api' ? sanitizeScopes(scopes) : null; + const result = this.db.run( - 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)', - userId, name.trim(), tokenHash, tokenPrefix, kind + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)', + userId, name.trim(), tokenHash, tokenPrefix, kind, + narrowed ? 'limited' : 'all', + narrowed ? JSON.stringify(narrowed) : null, ); const token = this.db.get( @@ -93,7 +129,10 @@ export class TokenService { result.lastInsertRowid ); - return { token: { ...(token as object), raw_token: rawToken } }; + const grant = kind === 'api' + ? { scope_mode: narrowed ? 'limited' : 'all', scopes: narrowed ?? [...PUBLIC_API_SCOPES] } + : {}; + return { token: { ...(token as object), ...grant, raw_token: rawToken } }; } deleteMcpToken(userId: number, tokenId: string) { @@ -183,6 +222,27 @@ export class TokenService { return this.verifyToken(rawToken, 'api'); } + /** + * The same lookup, plus what the key is allowed to read. + * + * A second method rather than a widened `verifyApiToken`: the guard needs + * both halves, nothing else needs either, and the existing signature is + * pinned by tests that assert exactly a `User`. + */ + verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null { + const hash = createHash('sha256').update(rawToken).digest('hex'); + const row = this.db.get(` + SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes + FROM mcp_tokens mt + JOIN users u ON mt.user_id = u.id + WHERE mt.token_hash = ? AND mt.kind = 'api' + `, hash); + if (!row) return null; + this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash); + const { scope_mode, api_scopes, ...user } = row; + return { user: user as User, grant: resolveGrant(scope_mode, api_scopes) }; + } + /** * Hash, look up, and require the kind the calling surface accepts. * @@ -206,3 +266,58 @@ export class TokenService { return null; } } + +interface TokenRow { + id: number; + name: string; + token_prefix: string; + created_at: string; + last_used_at: string | null; + scope_mode: string | null; + api_scopes: string | null; +} + +/** + * The requested sections, cleaned up — unknown values dropped, duplicates + * collapsed, order pinned to the canonical list so two keys with the same + * access read the same in the panel. + * + * Returns null for "no narrowing was asked for", which is what makes the caller + * store full access rather than an empty list that would lock the key out of + * everything. + */ +function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[] | null { + if (!scopes || scopes.length === 0) return null; + const wanted = new Set(scopes); + const kept = PUBLIC_API_SCOPES.filter((scope) => wanted.has(scope)); + return kept.length > 0 ? [...kept] : null; +} + +/** + * What a stored row means, read defensively — in both directions. + * + * A row that does not say 'limited' is a full-access key: that is what every key + * written before this column existed had, and a missing narrowing must not cost + * a running integration its access. Narrowing is a deliberate act; its absence + * is not a denial. + * + * But once a row HAS said 'limited', every way of failing to read its list + * denies. Unparseable JSON, a non-array, or a list of scope names this version + * no longer knows (a renamed constant, a hand-edited row) resolves to no scopes + * at all rather than to everything: the whole reason `scope_mode` is an explicit + * flag instead of "NULL means everything" is that a key minted as restricted + * must never widen on its own. A key that stops working is a support ticket; a + * key that quietly reads every trip is the bug this feature exists to prevent. + */ +function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant { + if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] }; + let parsed: unknown; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + parsed = null; + } + if (!Array.isArray(parsed)) return { mode: 'limited', scopes: [] }; + const kept = sanitizeScopes(parsed.filter((value): value is string => typeof value === 'string')); + return { mode: 'limited', scopes: kept ?? [] }; +} diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts index 2c45b8076..ad1fcb642 100644 --- a/server/tests/e2e/public-api.e2e.test.ts +++ b/server/tests/e2e/public-api.e2e.test.ts @@ -21,10 +21,15 @@ const { db } = vi.hoisted(() => { tmp.exec(` CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0); + -- scope_mode/api_scopes are spelled exactly as the scopes migration adds + -- them (NOT NULL DEFAULT 'all', and a nullable JSON list): a row here has to + -- be able to be the row the guard reads in production, or the scope cases + -- below would be passing against a table that does not exist anywhere else. CREATE TABLE mcp_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME, - kind TEXT NOT NULL DEFAULT 'mcp'); + kind TEXT NOT NULL DEFAULT 'mcp', + scope_mode TEXT NOT NULL DEFAULT 'all', api_scopes TEXT); CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, currency TEXT, is_archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -76,6 +81,10 @@ vi.mock('../../src/db/database', async (importActual) => { import { DatabaseModule } from '../../src/nest/database/database.module'; import { RateLimitModule } from '../../src/nest/common/rate-limit.module'; import { PublicApiModule } from '../../src/nest/public-api/public-api.module'; +import { ApiTokenGuard } from '../../src/nest/public-api/api-token.guard'; +import { TokensModule } from '../../src/nest/tokens/tokens.module'; +import { PublicStatsController } from '../../src/nest/atlas/public-stats.controller'; +import { AtlasService } from '../../src/nest/atlas/atlas.service'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; /** Mints a token the way TokenService does, so the guard's hash lookup is real. */ @@ -90,17 +99,67 @@ function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): st return raw; } +/** + * The same mint, narrowed. `rawScopes` goes into the column verbatim — including + * the deliberately unparseable value one case needs, which is why this takes a + * string rather than an array. + */ +function seedLimitedToken(userId: number, raw: string, rawScopes: string): string { + db.prepare( + 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)', + ).run( + userId, + 'test-limited', + createHash('sha256').update(raw).digest('hex'), + raw.slice(0, 13), + 'api', + 'limited', + rawScopes, + ); + return raw; +} + const ADA_TOKEN = 'trek_' + 'a'.repeat(48); const BOB_TOKEN = 'trek_' + 'b'.repeat(48); /** A valid credential for /mcp — must not open this surface. */ const MCP_TOKEN = 'trek_' + 'c'.repeat(48); +/** Narrowed keys, all Ada's, so only the grant differs between them. */ +const TRIPS_ONLY_TOKEN = 'trek_' + 'd'.repeat(48); +const TRIPS_DAYS_TOKEN = 'trek_' + 'e'.repeat(48); +/** api_scopes that cannot be parsed — the fall-back-to-everything case. */ +const BROKEN_SCOPES_TOKEN = 'trek_' + 'f'.repeat(48); + +/** Stubbed so /api/v1/stats can be mounted without AtlasModule's whole graph. */ +const STATS = { + getTravelStats: () => ({ + countries: ['JP', 'IT'], + cities: ['tokyo'], + coords: [], + totalTrips: 2, + totalDays: 3, + totalPlaces: 4, + totalDistanceKm: 1234, + }), + lastTrip: () => null, +}; describe('Public API v1 e2e (real guard + real SQL)', () => { let server: Server; let app: Awaited>; async function build() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RateLimitModule, PublicApiModule] }).compile(); + const moduleRef = await Test.createTestingModule({ + imports: [DatabaseModule, RateLimitModule, TokensModule, PublicApiModule], + // `/api/v1/stats` lives in atlas/ because its figures do, but it is guarded + // and scoped by this directory's code — so it is mounted here with the real + // guard and a stubbed AtlasService. Importing AtlasModule instead would pull + // AuthModule and the storage registry, which reads app_settings on init and + // would need half the schema this file deliberately does not carry. What is + // under test is the refusal, not the arithmetic (public-stats.controller.test + // owns that), and the refusal happens before AtlasService is ever touched. + controllers: [PublicStatsController], + providers: [ApiTokenGuard, { provide: AtlasService, useValue: STATS }], + }).compile(); const nest = moduleRef.createNestApplication(); nest.useGlobalFilters(new TrekExceptionFilter()); await nest.init(); @@ -113,6 +172,13 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { seedToken(1, ADA_TOKEN); seedToken(2, BOB_TOKEN); seedToken(1, MCP_TOKEN, 'mcp'); + // ADA_TOKEN above is seeded without ever naming the two scope columns — the + // row a key minted before the feature existed leaves behind. It is the + // backwards-compatibility case and every existing test in this file rides on + // it, so if narrowing ever leaks into the default those tests fail first. + seedLimitedToken(1, TRIPS_ONLY_TOKEN, JSON.stringify(['trips'])); + seedLimitedToken(1, TRIPS_DAYS_TOKEN, JSON.stringify(['trips', 'days', 'notes'])); + seedLimitedToken(1, BROKEN_SCOPES_TOKEN, '{"not":"a list"'); // Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member. db.prepare( @@ -359,4 +425,165 @@ describe('Public API v1 e2e (real guard + real SQL)', () => { expect(res.status).toBe(401); }); }); + /** + * Read scopes (#2279). + * + * The question a mock cannot answer: does a key stored in the database a + * particular way actually reach the sections it should, and stop at the ones it + * should not? Every case below starts from a real row and ends at a real HTTP + * status, because the two halves — what the column says and what the route does + * — are written in different files and have no reason to agree on their own. + * + * The first case is the important one. Narrowing is a feature; not breaking the + * keys that already exist is a promise. + */ + describe('read scopes', () => { + it('PUBAPI-SCOPE-001: a key minted before scopes existed is stored as a full grant', () => { + const row = db + .prepare('SELECT scope_mode, api_scopes FROM mcp_tokens WHERE token_prefix = ?') + .get(ADA_TOKEN.slice(0, 13)) as { scope_mode: string; api_scopes: string | null }; + // The two columns were never named at insert time — exactly the row the + // ALTER leaves behind for a key that already existed. + expect(row).toEqual({ scope_mode: 'all', api_scopes: null }); + }); + + it('PUBAPI-SCOPE-002: and still reads every section, which is the whole promise', async () => { + const [trips, bucket, stats, trip] = await Promise.all([ + get('/api/v1/trips', ADA_TOKEN), + get('/api/v1/bucket-list', ADA_TOKEN), + get('/api/v1/stats', ADA_TOKEN), + get('/api/v1/trips/1', ADA_TOKEN), + ]); + expect([trips.status, bucket.status, stats.status, trip.status]).toEqual([200, 200, 200, 200]); + expect(stats.body).toMatchObject({ total_trips: 2, total_countries: 2 }); + // The full payload, not a narrowed one: every section is there. + expect(trip.body.days).toHaveLength(2); + expect(trip.body.accommodations).toBeDefined(); + expect(trip.body.travellers).toBeDefined(); + expect(trip.body.unplanned_places).toBeDefined(); + expect(trip.body.unscheduled_reservations).toBeDefined(); + }); + + it('PUBAPI-SCOPE-003: a key narrowed to trips reads trips', async () => { + const res = await get('/api/v1/trips', TRIPS_ONLY_TOKEN); + expect(res.status).toBe(200); + expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([1, 3]); + }); + + it('PUBAPI-SCOPE-004: and is refused the bucket list, with a code of its own', async () => { + const res = await get('/api/v1/bucket-list', TRIPS_ONLY_TOKEN); + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'This API key is not allowed to read bucket-list', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'bucket-list', + }); + // And nothing of the withheld list leaked out through the refusal. + expect(JSON.stringify(res.body)).not.toContain('Hokkaido'); + }); + + it('PUBAPI-SCOPE-005: and refused the stats route, the widest answer on the surface', async () => { + const res = await get('/api/v1/stats', TRIPS_ONLY_TOKEN); + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'This API key is not allowed to read stats', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'stats', + }); + }); + + /** + * A valid credential that may not read something is not an authentication + * problem, and the two have to stay distinguishable: re-minting a key fixes a + * 401 and does nothing at all for a 403. + */ + it('PUBAPI-SCOPE-006: the refusal is a 403 and leaves both 401 bodies exactly as they were', async () => { + const [none, unknown, wrongKind, noneOnStats] = await Promise.all([ + get('/api/v1/trips'), + get('/api/v1/trips', 'trek_' + 'z'.repeat(48)), + get('/api/v1/trips', MCP_TOKEN), + get('/api/v1/stats'), + ]); + expect(none.status).toBe(401); + expect(none.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }); + expect(unknown.status).toBe(401); + expect(unknown.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' }); + expect(wrongKind.status).toBe(401); + expect(wrongKind.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' }); + expect(noneOnStats.status).toBe(401); + expect(noneOnStats.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' }); + }); + + it('PUBAPI-SCOPE-007: a trip asked for without include carries only the granted sections', async () => { + const res = await get('/api/v1/trips/1', TRIPS_ONLY_TOKEN); + // Not a refusal: `include` absent means "everything", and a key that never + // named a forbidden section gets what it may have. + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: 1, title: 'Toskana' }); + expect(res.body.days).toBeUndefined(); + expect(res.body.accommodations).toBeUndefined(); + expect(res.body.travellers).toBeUndefined(); + expect(res.body.unplanned_places).toBeUndefined(); + expect(res.body.unscheduled_reservations).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain('Uffizien'); + }); + + it('PUBAPI-SCOPE-008: a wider grant fills in exactly the sections it covers', async () => { + const res = await get('/api/v1/trips/1', TRIPS_DAYS_TOKEN); + expect(res.status).toBe(200); + expect(res.body.days).toHaveLength(2); + expect(res.body.days[0].day_notes).toEqual([{ text: 'Tickets mitnehmen', time: '09:00' }]); + // notes was granted, places was not — and days is not a back door to them. + expect(res.body.days[0].places).toEqual([]); + expect(res.body.accommodations).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain('Uffizien'); + }); + + it('PUBAPI-SCOPE-009: an include the key does cover still answers', async () => { + const res = await get('/api/v1/trips/1?include=days,notes', TRIPS_DAYS_TOKEN); + expect(res.status).toBe(200); + expect(res.body.days[0].day_notes).toHaveLength(1); + }); + + it('PUBAPI-SCOPE-010: naming a forbidden section is refused rather than silently dropped', async () => { + const res = await get('/api/v1/trips/1?include=days,places', TRIPS_DAYS_TOKEN); + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'This API key is not allowed to read places', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'places', + }); + }); + + it('PUBAPI-SCOPE-011: a key that says it is narrowed and cannot say how reads nothing', async () => { + // The direction matters. A key that never said 'limited' keeps the full + // access it was minted with; one that HAS said it is restricted must not + // widen because its list became unreadable — a hand-edited row, or a scope + // constant a later version renamed away. Failing closed turns that into a + // support ticket instead of a key quietly reading every trip. + const [bucket, trip] = await Promise.all([ + get('/api/v1/bucket-list', BROKEN_SCOPES_TOKEN), + get('/api/v1/trips/1', BROKEN_SCOPES_TOKEN), + ]); + expect(bucket.status).toBe(403); + expect(bucket.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN' }); + expect(trip.status).toBe(403); + }); + + it('PUBAPI-SCOPE-012: a scope never widens access — the owner check still decides', async () => { + // 'trips' granted, Bob's trip asked for: a grant narrows what a key may read + // and can never hand it a row its owner cannot reach. + const res = await get('/api/v1/trips/2', TRIPS_ONLY_TOKEN); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + it('PUBAPI-SCOPE-013: a narrowed key is stamped as used like any other', async () => { + await get('/api/v1/trips', TRIPS_ONLY_TOKEN); + const row = db + .prepare('SELECT last_used_at FROM mcp_tokens WHERE token_prefix = ?') + .get(TRIPS_ONLY_TOKEN.slice(0, 13)) as { last_used_at: string | null }; + expect(row.last_used_at).not.toBeNull(); + }); + }); }); diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts new file mode 100644 index 000000000..5e4746e96 --- /dev/null +++ b/server/tests/unit/nest/public-api-scopes.test.ts @@ -0,0 +1,681 @@ +/** + * Per-key read scopes for /api/v1 (#2279). + * + * The feature is spread over four files that each know one third of it: the + * shared vocabulary, the column and how it is read back (TokenService), the + * grant on the request (ApiTokenGuard), and the refusal (public-api-request, + * used from two controllers in two modules). Nothing forces those halves to + * agree, so the cases below walk the whole path — a row in, a status out — and + * pin the one rule that is easy to get backwards on the way: + * + * a key that was never narrowed reads everything. + * + * That is not a nicety. Every integration key minted before this shipped has an + * empty `api_scopes`, and the day "no list" starts meaning "no access" is the + * day every one of them stops working. + * + * The end-to-end shape (real HTTP, real hash lookup) lives in + * tests/e2e/public-api.e2e.test.ts; this file owns the branches that are + * awkward to reach through a socket — a NULL column, a key the guard never + * touched, an unknown scope name. + */ + +// --------------------------------------------------------------------------- +// vi.hoisted: real in-memory DB + the module mock, before any import +// --------------------------------------------------------------------------- + +const { testDb, dbMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + return { + testDb: db, + dbMock: { + db, + closeDb: () => {}, + reinitialize: () => {}, + canAccessTrip: () => undefined, + isOwner: () => false, + }, + }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); +vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); +vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Imports (after the mocks) +// --------------------------------------------------------------------------- + +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; +import { + PUBLIC_API_INCLUDES, + PUBLIC_API_SCOPES, + apiTokenCreateRequestSchema, + mcpTokenCreateRequestSchema, + type PublicApiGrant, + type PublicApiScope, +} from '@trek/shared'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { TokenService } from '../../../src/nest/tokens/token.service'; +import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service'; +import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard'; +import { PublicApiController } from '../../../src/nest/public-api/public-api.controller'; +import type { PublicApiService } from '../../../src/nest/public-api/public-api.service'; +import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller'; +import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import { + grantedScopes, + narrowToGrant, + requireScope, +} from '../../../src/nest/public-api/public-api-request'; +import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; +import { AuthController } from '../../../src/nest/auth/auth.controller'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; +import type { UserProfileService } from '../../../src/nest/auth/user-profile.service'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import type { StorageService } from '../../../src/nest/storage/storage.service'; +import type { User } from '../../../src/types'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const ALL: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] }; +const limited = (...scopes: PublicApiScope[]): PublicApiGrant => ({ mode: 'limited', scopes }); + +/** A request as the guard leaves it: a user and a grant, nothing else. */ +const req = (grant: PublicApiGrant | undefined = ALL, userId: number | null = 7) => + ({ + headers: {}, + user: userId === null ? undefined : ({ id: userId } as User), + apiToken: grant, + }) as Request; + +const TRIP = { + id: 12, + title: 'Toskana', + description: null, + start_date: '2026-06-14', + end_date: '2026-06-22', + currency: 'EUR', + archived: false, + updated_at: '2026-06-01 10:00:00', +}; + +function thrown(fn: () => unknown): { status: number; body: unknown } { + try { + fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to refuse'); +} + +function apiController(svc: Partial) { + const rl = { check: vi.fn().mockReturnValue(true) } as unknown as RateLimitService; + return new PublicApiController(svc as PublicApiService, rl); +} + +function statsController() { + return new PublicStatsController( + { + getTravelStats: vi.fn(() => ({ + countries: ['JP'], cities: ['tokyo'], coords: [], + totalTrips: 1, totalDays: 2, totalPlaces: 3, totalDistanceKm: 4, + })), + lastTrip: vi.fn(() => null), + } as unknown as AtlasService, + new RateLimitService(), + ); +} + +// --------------------------------------------------------------------------- +// The grant on the request +// --------------------------------------------------------------------------- + +describe('public-api scopes — the grant on the request', () => { + it('PUBAPI-SCOPE-U001: a request the guard never touched may read nothing', () => { + // Fail closed. A route mounted without the guard is a bug, and the answer to + // a bug in an authorisation path is "no", never "everything". + expect(grantedScopes({} as Request)).toEqual([]); + }); + + it('PUBAPI-SCOPE-U002: a resolved grant is what the request reports, verbatim', () => { + expect(grantedScopes(req(limited('trips', 'days')))).toEqual(['trips', 'days']); + expect(grantedScopes(req(ALL))).toEqual([...PUBLIC_API_SCOPES]); + }); + + it('PUBAPI-SCOPE-U003: a missing scope is a 403 with a code of its own, never a 401 or a 404', () => { + // 401 would tell an integrator to re-mint a key that is perfectly valid; 404 + // would send them looking for a trip that is right there. Both are worse than + // saying what actually happened. + expect(thrown(() => requireScope(req(limited('trips')), 'stats'))).toEqual({ + status: 403, + body: { + error: 'This API key is not allowed to read stats', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'stats', + }, + }); + }); + + it('PUBAPI-SCOPE-U004: a granted scope passes without comment', () => { + expect(() => requireScope(req(limited('trips')), 'trips')).not.toThrow(); + for (const scope of PUBLIC_API_SCOPES) { + expect(() => requireScope(req(ALL), scope)).not.toThrow(); + } + }); + + it('PUBAPI-SCOPE-U005: narrowToGrant filters instead of refusing, and keeps the caller’s order', () => { + expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips', 'days', 'notes')))).toEqual([ + 'days', + 'notes', + ]); + expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(ALL))).toEqual([...PUBLIC_API_INCLUDES]); + expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips')))).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// ApiTokenGuard — resolving the grant +// --------------------------------------------------------------------------- + +describe('ApiTokenGuard — the grant it leaves behind', () => { + const USER = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User; + + function contextWith(headers: Record) { + const request: Record = { headers }; + return { + switchToHttp: () => ({ getRequest: () => request }), + req: request, + } as unknown as ExecutionContext & { req: Record }; + } + + function guardWith(result: { user: User; grant: PublicApiGrant } | null) { + const verifyApiTokenWithGrant = vi.fn().mockReturnValue(result); + return { + guard: new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService), + verifyApiTokenWithGrant, + }; + } + + it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', () => { + const grant = limited('trips'); + const { guard, verifyApiTokenWithGrant } = guardWith({ user: USER, grant }); + const ctx = contextWith({ authorization: 'Bearer trek_abc123' }); + expect(guard.canActivate(ctx)).toBe(true); + expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123'); + expect(ctx.req.user).toBe(USER); + expect(ctx.req.apiToken).toBe(grant); + // The grant belongs to the credential, not to the person: nothing of it may + // end up on `user`, which every other auth path in TREK also reads. + expect(USER).not.toHaveProperty('scopes'); + expect(USER).not.toHaveProperty('scope_mode'); + }); + + /** + * The two refusals predate scopes and are load-bearing for every integration + * that already handles them. Adding a third answer must not reword them. + */ + it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', () => { + const { guard: noCredential } = guardWith(null); + expect(thrown(() => noCredential.canActivate(contextWith({})))).toEqual({ + status: 401, + body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, + }); + const { guard: unknownToken } = guardWith(null); + expect( + thrown(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), + ).toEqual({ + status: 401, + body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, + }); + }); + + it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', () => { + const { guard } = guardWith(null); + const ctx = contextWith({ authorization: 'Bearer trek_nope' }); + thrown(() => guard.canActivate(ctx)); + expect(ctx.req.apiToken).toBeUndefined(); + expect(ctx.req.user).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// The two controllers behind the guard +// --------------------------------------------------------------------------- + +describe('PublicApiController — what a narrowed key reaches', () => { + it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', () => { + const listTrips = vi.fn().mockReturnValue([TRIP]); + const listBucketList = vi.fn().mockReturnValue([]); + const getTrip = vi.fn().mockReturnValue(TRIP); + const ctl = apiController({ listTrips, listBucketList, getTrip }); + expect(ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] }); + expect(ctl.listBucketList(req(ALL))).toEqual({ items: [] }); + expect(ctl.getTrip(req(ALL), '12', undefined)).toEqual(TRIP); + expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES]); + }); + + it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', () => { + const listTrips = vi.fn().mockReturnValue([TRIP]); + expect(apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] }); + }); + + it('PUBAPI-SCOPE-U022: and is refused the bucket list before the service is ever asked', () => { + const listBucketList = vi.fn(); + const res = thrown(() => apiController({ listBucketList }).listBucketList(req(limited('trips')))); + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN', required_scope: 'bucket-list' }); + // The refusal has to happen before the read, not after it: a 403 carrying + // rows that were already fetched is one careless `return` from leaking them. + expect(listBucketList).not.toHaveBeenCalled(); + }); + + it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', () => { + const listTrips = vi.fn(); + const getTrip = vi.fn(); + const ctl = apiController({ listTrips, getTrip }); + expect(thrown(() => ctl.listTrips(req(limited('bucket-list')))).status).toBe(403); + expect(thrown(() => ctl.getTrip(req(limited('bucket-list')), '12', undefined)).status).toBe(403); + expect(listTrips).not.toHaveBeenCalled(); + expect(getTrip).not.toHaveBeenCalled(); + }); + + it('PUBAPI-SCOPE-U024: a trip asked for without include is narrowed, not refused', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', undefined); + // `include` absent means "everything"; refusing a key for wanting sections it + // never named would make a narrow key unable to read a trip at all. + expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']); + }); + + it('PUBAPI-SCOPE-U025: a trips-only key gets the summary and nothing that hangs off it', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + apiController({ getTrip }).getTrip(req(limited('trips')), '12', undefined); + expect(getTrip).toHaveBeenCalledWith(12, 7, []); + }); + + it('PUBAPI-SCOPE-U026: an empty include is treated like an absent one, and narrowed too', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', ' '); + expect(getTrip).toHaveBeenCalledWith(12, 7, ['days']); + }); + + it('PUBAPI-SCOPE-U027: a section named explicitly and not granted is a 403, not a silent drop', () => { + const getTrip = vi.fn(); + const res = thrown(() => + apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', 'days,places'), + ); + expect(res).toEqual({ + status: 403, + body: { + error: 'This API key is not allowed to read places', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'places', + }, + }); + expect(getTrip).not.toHaveBeenCalled(); + }); + + it('PUBAPI-SCOPE-U028: an include the key covers is passed through untouched', () => { + const getTrip = vi.fn().mockReturnValue(TRIP); + apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', 'days, notes'); + expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']); + }); + + it('PUBAPI-SCOPE-U029: a bad id is still a 400 — the scope check does not swallow it', () => { + const getTrip = vi.fn(); + expect(thrown(() => apiController({ getTrip }).getTrip(req(ALL), 'abc', undefined)).status).toBe(400); + expect(getTrip).not.toHaveBeenCalled(); + }); +}); + +describe('PublicStatsController — the widest answer on the surface', () => { + it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', () => { + // The route lives in atlas/ and imports the check from public-api/. If it ever + // stops calling it, nothing else in that module would notice. + const res = thrown(() => statsController().stats(req(limited('trips')))); + expect(res).toEqual({ + status: 403, + body: { + error: 'This API key is not allowed to read stats', + code: 'API_SCOPE_FORBIDDEN', + required_scope: 'stats', + }, + }); + }); + + it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', () => { + expect(statsController().stats(req(limited('stats')))).toMatchObject({ + total_trips: 1, + total_countries: 1, + }); + }); + + it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', () => { + expect(statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 }); + }); +}); + +// --------------------------------------------------------------------------- +// The column: what is written, and what is read back out of it +// --------------------------------------------------------------------------- + +describe('TokenService — storing and resolving a grant', () => { + const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService()); + + beforeAll(() => { + createTables(testDb); + runMigrations(testDb); + }); + + beforeEach(() => { + resetTestDb(testDb); + vi.clearAllMocks(); + }); + + afterAll(() => { + testDb.close(); + }); + + const rowFor = (id: number) => + testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as { + kind: string; + scope_mode: string | null; + api_scopes: string | null; + }; + + const mint = (userId: number, name: string, scopes?: readonly string[]) => + tokens.createApiToken(userId, name, scopes).token as { + id: number; + raw_token: string; + scope_mode: string; + scopes: string[]; + }; + + it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich'); + // 'all' is written explicitly: a NULL sentinel here would mean a bug that + // drops the column silently grants everything. + expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null }); + expect(created.scope_mode).toBe('all'); + expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]); + }); + + it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', () => { + const { user } = createUser(testDb); + const raw = mint(user.id, 'Dawarich').raw_token; + const resolved = tokens.verifyApiTokenWithGrant(raw); + expect(resolved?.user.id).toBe(user.id); + expect(resolved?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES] }); + }); + + /** + * The backwards-compatibility case, at the layer that decides it. A row written + * before the columns existed carries neither value; the ALTER backfills + * 'all', and a DatabaseService that hands back NULL anyway must land in the + * same place. Both are "nobody ever narrowed this key". + */ + it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', () => { + const noColumns = new TokenService( + { + get: () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }), + run: () => ({ changes: 1 }), + } as unknown as DatabaseService, + new EphemeralTokenService(), + ); + expect(noColumns.verifyApiTokenWithGrant('trek_legacy')?.grant).toEqual({ + mode: 'all', + scopes: [...PUBLIC_API_SCOPES], + }); + }); + + it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['stats', 'trips']); + expect(rowFor(created.id)).toEqual({ + kind: 'api', + scope_mode: 'limited', + // Canonical order, not the caller's: two keys with the same access must + // read identically in the settings list. + api_scopes: JSON.stringify(['trips', 'stats']), + }); + expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] }); + }); + + it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', () => { + const { user } = createUser(testDb); + const raw = mint(user.id, 'Dawarich', ['trips', 'days']).raw_token; + expect(tokens.verifyApiTokenWithGrant(raw)?.grant).toEqual({ + mode: 'limited', + scopes: ['trips', 'days'], + }); + }); + + it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']); + expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips'])); + }); + + it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', () => { + const { user } = createUser(testDb); + // Not an empty limited grant: that would mint a key that can read nothing, + // which nobody asked for and which fails hours later at the integration. + const created = mint(user.id, 'Dawarich', ['everything']); + expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + }); + + it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', () => { + const { user } = createUser(testDb); + expect(rowFor(mint(user.id, 'Dawarich', []).id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + }); + + it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', () => { + // The direction matters. A row that never said 'limited' is a legacy + // full-access key and stays one; a row that HAS said it is restricted must + // never widen because its list became unreadable — that is the failure this + // whole column exists to prevent. A key that stops working is a support + // ticket; a key that quietly reads every trip is the bug. + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['trips']); + for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) { + testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id); + expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + mode: 'limited', + scopes: [], + }); + } + }); + + it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['trips']); + // What a future version dropping or renaming a scope constant leaves behind. + testDb + .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') + .run(JSON.stringify(['itineraries']), created.id); + expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + mode: 'limited', + scopes: [], + }); + }); + + it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['trips']); + testDb + .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?') + .run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id); + expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({ + mode: 'limited', + scopes: ['trips', 'stats'], + }); + }); + + it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', () => { + const { user } = createUser(testDb); + mint(user.id, 'Wide'); + mint(user.id, 'Narrow', ['trips']); + const listed = tokens.listApiTokens(user.id) as Array>; + expect(listed).toHaveLength(2); + expect(listed.map((t) => [t.name, t.scope_mode, t.scopes])).toEqual( + expect.arrayContaining([ + ['Wide', 'all', [...PUBLIC_API_SCOPES]], + ['Narrow', 'limited', ['trips']], + ]), + ); + // Still no hash, and still no raw key. + expect(JSON.stringify(listed)).not.toContain('token_hash'); + }); + + it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', () => { + const { user } = createUser(testDb); + const mcp = tokens.createMcpToken(user.id, 'Assistant').token as { id: number }; + // Stored as the column default, because an MCP token carries no read scopes + // and must not look as if it did. + expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); + const listed = tokens.listMcpTokens(user.id) as Array>; + expect(listed).toHaveLength(1); + // The MCP panel cannot act on scopes, so it is not shown two columns that + // would always say the same thing. + expect(listed[0]).not.toHaveProperty('scope_mode'); + expect(listed[0]).not.toHaveProperty('scopes'); + expect(Object.keys(listed[0]).sort()).toEqual( + ['created_at', 'id', 'last_used_at', 'name', 'token_prefix'].sort(), + ); + }); + + it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', () => { + const { user } = createUser(testDb); + const raw = mint(user.id, 'Dawarich', ['trips']).raw_token; + expect(tokens.verifyMcpToken(raw)).toBeNull(); + expect(tokens.verifyApiTokenWithGrant(raw)).not.toBeNull(); + }); + + it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', () => { + const { user } = createUser(testDb); + const created = mint(user.id, 'Dawarich', ['trips']); + expect( + (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { + last_used_at: string | null; + }).last_used_at, + ).toBeNull(); + tokens.verifyApiTokenWithGrant(created.raw_token); + expect( + (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as { + last_used_at: string | null; + }).last_used_at, + ).not.toBeNull(); + }); + + // ------------------------------------------------------------------------- + // The mint routes. AuthController is constructed directly (repo convention) + // over the real TokenService above, so the body really does reach the column. + // ------------------------------------------------------------------------- + + const authController = () => + new AuthController( + { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() } as unknown as AuthService, + {} as UserProfileService, + tokens, + new RateLimitService(), + { writeAudit: vi.fn() } as unknown as AuditService, + new RuntimeEnvService(), + { put: vi.fn() } as unknown as StorageService, + ); + const httpReq = { ip: '9.9.9.9', headers: {} } as Request; + + it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', () => { + const { user } = createUser(testDb); + const created = authController().createApiToken( + { id: user.id } as User, + { name: 'Dawarich', scopes: ['trips', 'days'] }, + httpReq, + ).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string }; + + expect(rowFor(created.id)).toMatchObject({ + scope_mode: 'limited', + api_scopes: JSON.stringify(['trips', 'days']), + }); + // The response carries the grant, so the panel can show it without a re-read. + expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'days'] }); + expect(created.raw_token).toMatch(/^trek_/); + }); + + it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', () => { + const { user } = createUser(testDb); + const ctl = authController(); + ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq); + expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({ + tokens: [expect.objectContaining({ name: 'Dawarich', scope_mode: 'limited', scopes: ['bucket-list'] })], + }); + }); + + it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', () => { + const { user } = createUser(testDb); + const ctl = authController(); + const created = ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq).token as { + id: number; + }; + expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null }); + expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({ + tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })], + }); + }); + + it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', () => { + const { user } = createUser(testDb); + const ctl = authController(); + // The DTO is a different schema on purpose, so the field never survives the + // pipe — and even handed straight to the controller it changes nothing. + const created = ctl.createMcpToken( + { id: user.id } as User, + { name: 'Assistant', scopes: ['trips'] } as { name: string }, + httpReq, + ).token as Record; + + expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null }); + expect(created).not.toHaveProperty('scope_mode'); + expect(created).not.toHaveProperty('scopes'); + expect(ctl.listMcpTokens({ id: user.id } as User).tokens[0]).not.toHaveProperty('scope_mode'); + }); + + it('PUBAPI-SCOPE-U064: the two create schemas differ, which is what keeps them apart', () => { + const api = apiTokenCreateRequestSchema.safeParse({ name: 'Dawarich', scopes: ['trips'] }); + expect(api.success && api.data.scopes).toEqual(['trips']); + // An unknown section is refused at the edge rather than quietly sanitised, so + // a typo in an integration's setup script is visible immediately. + expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: ['passwords'] }).success).toBe(false); + // An empty array would mint a key that reads nothing; omitting the field is + // how you ask for everything. + expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: [] }).success).toBe(false); + expect(apiTokenCreateRequestSchema.safeParse({ name: 'x' }).success).toBe(true); + + const mcp = mcpTokenCreateRequestSchema.safeParse({ name: 'Assistant', scopes: ['trips'] }); + expect(mcp.success).toBe(true); + // Parsed and dropped: the MCP route cannot be handed read scopes by accident. + expect(mcp.success && mcp.data).toEqual({ name: 'Assistant' }); + }); +}); diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts index bf4710f94..afa1cc6ce 100644 --- a/server/tests/unit/nest/public-api.controller.test.ts +++ b/server/tests/unit/nest/public-api.controller.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; -import { PUBLIC_API_INCLUDES } from '@trek/shared'; +import { PUBLIC_API_INCLUDES, PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared'; import { PublicApiController } from '../../../src/nest/public-api/public-api.controller'; import type { PublicApiService } from '../../../src/nest/public-api/public-api.service'; import type { RateLimitService } from '../../../src/nest/common/rate-limit.service'; @@ -40,9 +40,15 @@ function makeController(svc: Partial, allow = true) { return new PublicApiController(svc as PublicApiService, rl); } -const req = (userId = 7) => ({ user: { id: userId } }) as Request; -/** A request the guard never touched — the shape the controller must refuse. */ -const reqWithoutUser = () => ({}) as Request; +/** + * The guard leaves two things behind (#2279): who is calling, and what their key + * may read. Every request here carries the full grant, because these cases are + * about shaping; the narrowed ones live in public-api-scopes.test.ts. + */ +const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] }; +const req = (userId = 7) => ({ user: { id: userId }, apiToken: FULL_GRANT }) as Request; +/** A request the guard left no user on — the shape the controller must refuse. */ +const reqWithoutUser = () => ({ apiToken: FULL_GRANT }) as Request; function thrown(fn: () => unknown): { status: number; body: unknown } { try { diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts index a58ebd12d..a5094534e 100644 --- a/server/tests/unit/nest/public-api.guard.test.ts +++ b/server/tests/unit/nest/public-api.guard.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { ExecutionContext } from '@nestjs/common'; +import { PUBLIC_API_SCOPES } from '@trek/shared'; import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard'; import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { User } from '../../../src/types'; @@ -22,8 +23,17 @@ function contextWith(headers: Record) { } as unknown as ExecutionContext & { req: Record }; } +/** + * The guard resolves the user and the key's read grant in one lookup (#2279). + * These cases are about the credential, so every accepted token here carries the + * full grant; what a narrowed one may read is public-api-scopes.test.ts. + */ function makeGuard(verify: (raw: string) => User | null) { - return new ApiTokenGuard({ verifyApiToken: verify } as unknown as TokenService); + const verifyApiTokenWithGrant = (raw: string) => { + const user = verify(raw); + return user ? { user, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } } : null; + }; + return new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService); } /** Run the guard, expecting a refusal; return its { status, body }. */ @@ -70,11 +80,13 @@ describe('ApiTokenGuard', () => { }); it('never reaches for the MCP verifier, so the two credentials cannot swap', () => { - const verifyApiToken = vi.fn().mockReturnValue(USER); + const verifyApiTokenWithGrant = vi + .fn() + .mockReturnValue({ user: USER, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } }); const verifyMcpToken = vi.fn(); - const guard = new ApiTokenGuard({ verifyApiToken, verifyMcpToken } as unknown as TokenService); + const guard = new ApiTokenGuard({ verifyApiTokenWithGrant, verifyMcpToken } as unknown as TokenService); guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' })); - expect(verifyApiToken).toHaveBeenCalledTimes(1); + expect(verifyApiTokenWithGrant).toHaveBeenCalledTimes(1); expect(verifyMcpToken).not.toHaveBeenCalled(); }); diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index f66ee48f0..2e4405b2a 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -537,6 +537,21 @@ const settings: TranslationStrings = { 'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'ما يمكن لهذا المفتاح قراءته', + 'settings.apiScopes.hint': + 'اترك كل شيء مفعّلاً لمفتاح ينبغي أن يرى كل شيء. أي خيار توقفه يُرفض لهذا المفتاح، لا يُحذف من الإجابة فحسب.', + 'settings.apiScopes.all': 'كل شيء', + 'settings.apiScopes.limited': '{count} من {total}', + 'settings.apiScopes.trips': 'الرحلات', + 'settings.apiScopes.days': 'الأيام', + 'settings.apiScopes.places': 'الأماكن', + 'settings.apiScopes.notes': 'ملاحظات اليوم', + 'settings.apiScopes.reservations': 'الحجوزات', + 'settings.apiScopes.accommodations': 'الإقامة', + 'settings.apiScopes.travellers': 'من سيأتي', + 'settings.apiScopes.bucket-list': 'قائمة الأمنيات', + 'settings.apiScopes.stats': 'الإجماليات', 'settings.apiKeys.title': 'مفاتيح API', 'settings.apiKeys.description': 'مفاتيح لواجهة API العامة، حتى تتمكن برامج أخرى من قراءة رحلاتك. للقراءة فقط: لا يمكن للمفتاح تغيير أي شيء أو حذفه.', 'settings.apiKeys.create': 'إنشاء مفتاح', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index e8086a5ef..17001478c 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -552,6 +552,21 @@ const settings: TranslationStrings = { 'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'O que esta chave pode ler', + 'settings.apiScopes.hint': + 'Deixe tudo ligado para uma chave que deve enxergar tudo. O que você desligar é recusado para esta chave, não apenas omitido da resposta.', + 'settings.apiScopes.all': 'Tudo', + 'settings.apiScopes.limited': '{count} de {total}', + 'settings.apiScopes.trips': 'Viagens', + 'settings.apiScopes.days': 'Dias', + 'settings.apiScopes.places': 'Lugares', + 'settings.apiScopes.notes': 'Notas do dia', + 'settings.apiScopes.reservations': 'Reservas', + 'settings.apiScopes.accommodations': 'Hospedagem', + 'settings.apiScopes.travellers': 'Quem vai', + 'settings.apiScopes.bucket-list': 'Lista de desejos', + 'settings.apiScopes.stats': 'Totais', 'settings.apiKeys.title': 'Chaves de API', 'settings.apiKeys.description': 'Chaves para a API pública, para que outros softwares possam ler suas viagens. Somente leitura: uma chave não pode alterar nem excluir nada.', 'settings.apiKeys.create': 'Criar chave', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 1f7007457..d8620f1b0 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -555,6 +555,21 @@ const settings: TranslationStrings = { 'Dibuixa automàticament al mapa la ruta de cada vol, tren i altra reserva — no cal activar-la una per una.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Què pot llegir aquesta clau', + 'settings.apiScopes.hint': + 'Deixa-ho tot activat per a una clau que ho hagi de veure tot. El que desactivis es denega per a aquesta clau, no només es deixa fora de la resposta.', + 'settings.apiScopes.all': 'Tot', + 'settings.apiScopes.limited': '{count} de {total}', + 'settings.apiScopes.trips': 'Viatges', + 'settings.apiScopes.days': 'Dies', + 'settings.apiScopes.places': 'Llocs', + 'settings.apiScopes.notes': 'Notes del dia', + 'settings.apiScopes.reservations': 'Reserves', + 'settings.apiScopes.accommodations': 'Allotjament', + 'settings.apiScopes.travellers': 'Qui hi va', + 'settings.apiScopes.bucket-list': 'Llista de desitjos', + 'settings.apiScopes.stats': 'Totals', 'settings.apiKeys.title': 'Claus API', 'settings.apiKeys.description': 'Claus per a l\'API pública, perquè altres programes puguin llegir els teus viatges. Només lectura: una clau no pot canviar ni esborrar res.', 'settings.apiKeys.create': 'Crea una clau', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 099c66256..12da1ee09 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -544,6 +544,21 @@ const settings: TranslationStrings = { 'Automaticky zobrazí na mapě trasu každého letu, vlaku a jiné rezervace, aniž byste ji museli zapínat jednotlivě.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Co smí tento klíč číst', + 'settings.apiScopes.hint': + 'Pro klíč, který má vidět všechno, nechte vše zapnuté. Cokoli vypnete, bude tomuto klíči odepřeno, nejen vynecháno z odpovědi.', + 'settings.apiScopes.all': 'Vše', + 'settings.apiScopes.limited': '{count} z {total}', + 'settings.apiScopes.trips': 'Cesty', + 'settings.apiScopes.days': 'Dny', + 'settings.apiScopes.places': 'Místa', + 'settings.apiScopes.notes': 'Poznámky ke dni', + 'settings.apiScopes.reservations': 'Rezervace', + 'settings.apiScopes.accommodations': 'Ubytování', + 'settings.apiScopes.travellers': 'Kdo jede', + 'settings.apiScopes.bucket-list': 'Seznam přání', + 'settings.apiScopes.stats': 'Souhrny', 'settings.apiKeys.title': 'Klíče API', 'settings.apiKeys.description': 'Klíče pro veřejné API, aby jiný software mohl číst tvoje cesty. Jen pro čtení: klíč nemůže nic měnit ani mazat.', 'settings.apiKeys.create': 'Vytvořit klíč', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index d9e10d945..454e407e5 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -555,6 +555,21 @@ const settings: TranslationStrings = { 'Zeigt die Route für jeden Flug, jede Zugfahrt und jede andere Buchung automatisch auf der Karte an – ohne sie einzeln aktivieren zu müssen.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Was dieser Schlüssel lesen darf', + 'settings.apiScopes.hint': + 'Lass alles an, wenn der Schlüssel alles sehen soll. Was du abschaltest, wird für diesen Schlüssel abgelehnt und nicht nur aus der Antwort weggelassen.', + 'settings.apiScopes.all': 'Alles', + 'settings.apiScopes.limited': '{count} von {total}', + 'settings.apiScopes.trips': 'Reisen', + 'settings.apiScopes.days': 'Tage', + 'settings.apiScopes.places': 'Orte', + 'settings.apiScopes.notes': 'Tagesnotizen', + 'settings.apiScopes.reservations': 'Buchungen', + 'settings.apiScopes.accommodations': 'Unterkunft', + 'settings.apiScopes.travellers': 'Wer mitkommt', + 'settings.apiScopes.bucket-list': 'Wunschliste', + 'settings.apiScopes.stats': 'Summen', 'settings.apiKeys.title': 'API-Schlüssel', 'settings.apiKeys.description': 'Schlüssel für die öffentliche API, damit andere Software deine Reisen lesen kann. Nur lesend: ein Schlüssel kann nichts ändern oder löschen.', 'settings.apiKeys.create': 'Schlüssel erstellen', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index bd2f21608..c4182f486 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -543,6 +543,21 @@ const settings: TranslationStrings = { 'Automatically draw the route for every flight, train and other booking on the map — no need to switch it on per item.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'What this key may read', + 'settings.apiScopes.hint': + 'Leave everything on for a key that should see all of it. Anything you switch off is refused for this key, not just left out of the answer.', + 'settings.apiScopes.all': 'Everything', + 'settings.apiScopes.limited': '{count} of {total}', + 'settings.apiScopes.trips': 'Trips', + 'settings.apiScopes.days': 'Days', + 'settings.apiScopes.places': 'Places', + 'settings.apiScopes.notes': 'Day notes', + 'settings.apiScopes.reservations': 'Bookings', + 'settings.apiScopes.accommodations': 'Accommodation', + 'settings.apiScopes.travellers': 'Who is coming', + 'settings.apiScopes.bucket-list': 'Wishlist', + 'settings.apiScopes.stats': 'Totals', 'settings.apiKeys.title': 'API Keys', 'settings.apiKeys.description': 'Keys for the public API, so other software can read your trips. Read-only: a key cannot change or delete anything.', 'settings.apiKeys.create': 'Create key', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index d8ab3e55c..ba5d36976 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -553,6 +553,21 @@ const settings: TranslationStrings = { 'Muestra automáticamente en el mapa la ruta de cada vuelo, tren y otra reserva, sin necesidad de activarla una por una.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Qué puede leer esta clave', + 'settings.apiScopes.hint': + 'Déjalo todo activado para una clave que deba verlo todo. Lo que desactives se le deniega a esta clave, no solo se omite de la respuesta.', + 'settings.apiScopes.all': 'Todo', + 'settings.apiScopes.limited': '{count} de {total}', + 'settings.apiScopes.trips': 'Viajes', + 'settings.apiScopes.days': 'Días', + 'settings.apiScopes.places': 'Lugares', + 'settings.apiScopes.notes': 'Notas del día', + 'settings.apiScopes.reservations': 'Reservas', + 'settings.apiScopes.accommodations': 'Alojamiento', + 'settings.apiScopes.travellers': 'Quién viene', + 'settings.apiScopes.bucket-list': 'Lista de deseos', + 'settings.apiScopes.stats': 'Totales', 'settings.apiKeys.title': 'Claves API', 'settings.apiKeys.description': 'Claves para la API pública, para que otro software pueda leer tus viajes. Solo lectura: una clave no puede cambiar ni borrar nada.', 'settings.apiKeys.create': 'Crear clave', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index a36a95eeb..edb9d9b1d 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -561,6 +561,21 @@ const settings: TranslationStrings = { "Affiche automatiquement sur la carte l'itinéraire de chaque vol, trajet en train et autre réservation, sans avoir à l'activer un par un.", // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Ce que cette clé peut lire', + 'settings.apiScopes.hint': + 'Laissez tout activé pour une clé qui doit tout voir. Ce que vous désactivez est refusé à cette clé, et pas seulement omis de la réponse.', + 'settings.apiScopes.all': 'Tout', + 'settings.apiScopes.limited': '{count} sur {total}', + 'settings.apiScopes.trips': 'Voyages', + 'settings.apiScopes.days': 'Jours', + 'settings.apiScopes.places': 'Lieux', + 'settings.apiScopes.notes': 'Notes du jour', + 'settings.apiScopes.reservations': 'Réservations', + 'settings.apiScopes.accommodations': 'Hébergement', + 'settings.apiScopes.travellers': 'Qui participe', + 'settings.apiScopes.bucket-list': 'Liste de souhaits', + 'settings.apiScopes.stats': 'Totaux', 'settings.apiKeys.title': 'Clés API', 'settings.apiKeys.description': 'Clés pour l\'API publique, afin que d\'autres logiciels puissent lire vos voyages. Lecture seule : une clé ne peut rien modifier ni supprimer.', 'settings.apiKeys.create': 'Créer une clé', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 61a125201..ef4a4dae9 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -564,6 +564,21 @@ const settings: TranslationStrings = { 'Εμφανίζει αυτόματα στον χάρτη τη διαδρομή κάθε πτήσης, τρένου και άλλης κράτησης, χωρίς να χρειάζεται να την ενεργοποιείτε μία προς μία.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Τι μπορεί να διαβάσει αυτό το κλειδί', + 'settings.apiScopes.hint': + 'Αφήστε τα όλα ενεργά για ένα κλειδί που πρέπει να τα βλέπει όλα. Ό,τι απενεργοποιήσετε απορρίπτεται για αυτό το κλειδί, δεν παραλείπεται απλώς από την απάντηση.', + 'settings.apiScopes.all': 'Τα πάντα', + 'settings.apiScopes.limited': '{count} από {total}', + 'settings.apiScopes.trips': 'Ταξίδια', + 'settings.apiScopes.days': 'Ημέρες', + 'settings.apiScopes.places': 'Τοποθεσίες', + 'settings.apiScopes.notes': 'Σημειώσεις ημέρας', + 'settings.apiScopes.reservations': 'Κρατήσεις', + 'settings.apiScopes.accommodations': 'Διαμονή', + 'settings.apiScopes.travellers': 'Ποιοι συμμετέχουν', + 'settings.apiScopes.bucket-list': 'Λίστα επιθυμιών', + 'settings.apiScopes.stats': 'Σύνολα', 'settings.apiKeys.title': 'Κλειδιά API', 'settings.apiKeys.description': 'Κλειδιά για το δημόσιο API, ώστε άλλο λογισμικό να μπορεί να διαβάζει τα ταξίδια σου. Μόνο για ανάγνωση: ένα κλειδί δεν μπορεί να αλλάξει ή να διαγράψει τίποτα.', 'settings.apiKeys.create': 'Δημιουργία κλειδιού', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 16054699d..75d63aaca 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -554,6 +554,21 @@ const settings: TranslationStrings = { 'Automatikusan megjeleníti minden repülőjárat, vonat és egyéb foglalás útvonalát a térképen, nincs szükség egyenkénti bekapcsolásra.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Mit olvashat ez a kulcs', + 'settings.apiScopes.hint': + 'Hagyj mindent bekapcsolva, ha a kulcs mindent láthat. Amit kikapcsolsz, azt a rendszer megtagadja ettől a kulcstól, nem csak kihagyja a válaszból.', + 'settings.apiScopes.all': 'Minden', + 'settings.apiScopes.limited': '{count} / {total}', + 'settings.apiScopes.trips': 'Utazások', + 'settings.apiScopes.days': 'Napok', + 'settings.apiScopes.places': 'Helyszínek', + 'settings.apiScopes.notes': 'Napi jegyzetek', + 'settings.apiScopes.reservations': 'Foglalások', + 'settings.apiScopes.accommodations': 'Szállás', + 'settings.apiScopes.travellers': 'Kik utaznak', + 'settings.apiScopes.bucket-list': 'Kívánságlista', + 'settings.apiScopes.stats': 'Összesítések', 'settings.apiKeys.title': 'API-kulcsok', 'settings.apiKeys.description': 'Kulcsok a nyilvános API-hoz, hogy más szoftverek olvashassák az utazásaidat. Csak olvasható: a kulcs semmit nem módosít és nem töröl.', 'settings.apiKeys.create': 'Kulcs létrehozása', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index d955567d9..489b34f48 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -550,6 +550,21 @@ const settings: TranslationStrings = { 'Menampilkan rute setiap penerbangan, kereta, dan pemesanan lainnya di peta secara otomatis, tanpa perlu mengaktifkannya satu per satu.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Apa yang boleh dibaca kunci ini', + 'settings.apiScopes.hint': + 'Biarkan semuanya menyala untuk kunci yang boleh melihat semuanya. Apa pun yang kamu matikan akan ditolak untuk kunci ini, bukan sekadar tidak disertakan dalam jawaban.', + 'settings.apiScopes.all': 'Semuanya', + 'settings.apiScopes.limited': '{count} dari {total}', + 'settings.apiScopes.trips': 'Perjalanan', + 'settings.apiScopes.days': 'Hari', + 'settings.apiScopes.places': 'Tempat', + 'settings.apiScopes.notes': 'Catatan harian', + 'settings.apiScopes.reservations': 'Pemesanan', + 'settings.apiScopes.accommodations': 'Akomodasi', + 'settings.apiScopes.travellers': 'Siapa yang ikut', + 'settings.apiScopes.bucket-list': 'Daftar keinginan', + 'settings.apiScopes.stats': 'Total', 'settings.apiKeys.title': 'Kunci API', 'settings.apiKeys.description': 'Kunci untuk API publik, agar perangkat lunak lain dapat membaca perjalananmu. Hanya baca: kunci tidak dapat mengubah atau menghapus apa pun.', 'settings.apiKeys.create': 'Buat kunci', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 585b92183..cb0263f36 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -552,6 +552,21 @@ const settings: TranslationStrings = { 'Mostra automaticamente sulla mappa il percorso di ogni volo, treno e altra prenotazione, senza doverlo attivare singolarmente.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Cosa può leggere questa chiave', + 'settings.apiScopes.hint': + 'Lascia tutto attivo per una chiave che deve vedere ogni cosa. Ciò che disattivi viene rifiutato per questa chiave, non semplicemente omesso dalla risposta.', + 'settings.apiScopes.all': 'Tutto', + 'settings.apiScopes.limited': '{count} di {total}', + 'settings.apiScopes.trips': 'Viaggi', + 'settings.apiScopes.days': 'Giorni', + 'settings.apiScopes.places': 'Luoghi', + 'settings.apiScopes.notes': 'Note del giorno', + 'settings.apiScopes.reservations': 'Prenotazioni', + 'settings.apiScopes.accommodations': 'Alloggio', + 'settings.apiScopes.travellers': 'Chi partecipa', + 'settings.apiScopes.bucket-list': 'Lista dei desideri', + 'settings.apiScopes.stats': 'Totali', 'settings.apiKeys.title': 'Chiavi API', 'settings.apiKeys.description': 'Chiavi per l\'API pubblica, così altri software possono leggere i tuoi viaggi. Sola lettura: una chiave non può modificare né eliminare nulla.', 'settings.apiKeys.create': 'Crea chiave', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index 92254920e..675499ed7 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -523,6 +523,21 @@ const settings: TranslationStrings = { 'フライトや電車などすべての予約のルートを、個別にオンにしなくても地図上に自動的に表示します。', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'このキーが読み取れる範囲', + 'settings.apiScopes.hint': + 'すべて読み取らせたいキーは、全部オンのままにしてください。オフにした項目は、応答から省かれるのではなく、このキーには拒否されます。', + 'settings.apiScopes.all': 'すべて', + 'settings.apiScopes.limited': '{total}件中{count}件', + 'settings.apiScopes.trips': '旅行', + 'settings.apiScopes.days': '日程', + 'settings.apiScopes.places': '場所', + 'settings.apiScopes.notes': '日ごとのメモ', + 'settings.apiScopes.reservations': '予約', + 'settings.apiScopes.accommodations': '宿泊', + 'settings.apiScopes.travellers': '同行者', + 'settings.apiScopes.bucket-list': 'ウィッシュリスト', + 'settings.apiScopes.stats': '合計', 'settings.apiKeys.title': 'API キー', 'settings.apiKeys.description': '公開 API 用のキーです。ほかのソフトウェアが旅程を読み取れるようになります。読み取り専用で、変更や削除はできません。', 'settings.apiKeys.create': 'キーを作成', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 09b7cefd6..eb09abca2 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -540,6 +540,21 @@ const settings: TranslationStrings = { '항공편, 기차 등 모든 예약의 경로를 개별적으로 켤 필요 없이 지도에 자동으로 표시합니다.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': '이 키가 읽을 수 있는 항목', + 'settings.apiScopes.hint': + '모든 것을 볼 수 있어야 하는 키라면 전부 켜 두세요. 끈 항목은 이 키에 대해 응답에서 빠지는 정도가 아니라 아예 거부됩니다.', + 'settings.apiScopes.all': '전체', + 'settings.apiScopes.limited': '{total}개 중 {count}개', + 'settings.apiScopes.trips': '여행', + 'settings.apiScopes.days': '일자', + 'settings.apiScopes.places': '장소', + 'settings.apiScopes.notes': '일자 메모', + 'settings.apiScopes.reservations': '예약', + 'settings.apiScopes.accommodations': '숙소', + 'settings.apiScopes.travellers': '동행자', + 'settings.apiScopes.bucket-list': '위시리스트', + 'settings.apiScopes.stats': '합계', 'settings.apiKeys.title': 'API 키', 'settings.apiKeys.description': '공개 API용 키입니다. 다른 소프트웨어가 여행을 읽을 수 있습니다. 읽기 전용이라 무엇도 바꾸거나 지울 수 없습니다.', 'settings.apiKeys.create': '키 만들기', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 1db115f8e..ab6587085 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -553,6 +553,21 @@ const settings: TranslationStrings = { 'Toont automatisch de route van elke vlucht, trein en andere boeking op de kaart, zonder dat u dit per boeking hoeft in te schakelen.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Wat deze sleutel mag lezen', + 'settings.apiScopes.hint': + 'Laat alles aan staan voor een sleutel die alles mag zien. Alles wat je uitzet, wordt voor deze sleutel geweigerd en niet alleen uit het antwoord weggelaten.', + 'settings.apiScopes.all': 'Alles', + 'settings.apiScopes.limited': '{count} van {total}', + 'settings.apiScopes.trips': 'Reizen', + 'settings.apiScopes.days': 'Dagen', + 'settings.apiScopes.places': 'Plaatsen', + 'settings.apiScopes.notes': 'Dagnotities', + 'settings.apiScopes.reservations': 'Boekingen', + 'settings.apiScopes.accommodations': 'Accommodatie', + 'settings.apiScopes.travellers': 'Wie er meegaan', + 'settings.apiScopes.bucket-list': 'Wensenlijst', + 'settings.apiScopes.stats': 'Totalen', 'settings.apiKeys.title': 'API-sleutels', 'settings.apiKeys.description': 'Sleutels voor de publieke API, zodat andere software je reizen kan lezen. Alleen-lezen: een sleutel kan niets wijzigen of verwijderen.', 'settings.apiKeys.create': 'Sleutel aanmaken', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 65b6c39f6..91bde5c92 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -552,6 +552,21 @@ const settings: TranslationStrings = { 'Automatycznie pokazuje na mapie trasę każdego lotu, pociągu i innej rezerwacji, bez konieczności włączania jej pojedynczo.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Co ten klucz może odczytywać', + 'settings.apiScopes.hint': + 'Zostaw wszystko włączone dla klucza, który ma widzieć całość. Wszystko, co wyłączysz, jest dla tego klucza odrzucane, a nie tylko pomijane w odpowiedzi.', + 'settings.apiScopes.all': 'Wszystko', + 'settings.apiScopes.limited': '{count} z {total}', + 'settings.apiScopes.trips': 'Podróże', + 'settings.apiScopes.days': 'Dni', + 'settings.apiScopes.places': 'Miejsca', + 'settings.apiScopes.notes': 'Notatki dnia', + 'settings.apiScopes.reservations': 'Rezerwacje', + 'settings.apiScopes.accommodations': 'Noclegi', + 'settings.apiScopes.travellers': 'Kto jedzie', + 'settings.apiScopes.bucket-list': 'Lista marzeń', + 'settings.apiScopes.stats': 'Podsumowania', 'settings.apiKeys.title': 'Klucze API', 'settings.apiKeys.description': 'Klucze do publicznego API, aby inne oprogramowanie mogło odczytywać Twoje podróże. Tylko odczyt: klucz niczego nie zmieni ani nie usunie.', 'settings.apiKeys.create': 'Utwórz klucz', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 8550d8243..c1bbc08d0 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -554,6 +554,21 @@ const settings: TranslationStrings = { 'Автоматически показывает на карте маршрут для каждого рейса, поезда и другого бронирования — без необходимости включать это отдельно для каждого элемента.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Что этот ключ может читать', + 'settings.apiScopes.hint': + 'Оставьте всё включённым для ключа, который должен видеть всё. Всё, что вы выключите, для этого ключа будет запрещено, а не просто опущено в ответе.', + 'settings.apiScopes.all': 'Всё', + 'settings.apiScopes.limited': '{count} из {total}', + 'settings.apiScopes.trips': 'Поездки', + 'settings.apiScopes.days': 'Дни', + 'settings.apiScopes.places': 'Места', + 'settings.apiScopes.notes': 'Заметки дня', + 'settings.apiScopes.reservations': 'Бронирования', + 'settings.apiScopes.accommodations': 'Проживание', + 'settings.apiScopes.travellers': 'Кто едет', + 'settings.apiScopes.bucket-list': 'Список желаний', + 'settings.apiScopes.stats': 'Итоги', 'settings.apiKeys.title': 'Ключи API', 'settings.apiKeys.description': 'Ключи для публичного API, чтобы другие программы могли читать ваши поездки. Только чтение: ключ ничего не изменит и не удалит.', 'settings.apiKeys.create': 'Создать ключ', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 5b3fac127..0fe9c5b1c 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -549,6 +549,21 @@ const settings: TranslationStrings = { 'Visar automatiskt rutten för varje flyg, tåg och annan bokning på kartan, utan att du behöver aktivera det för varje bokning för sig.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Vad den här nyckeln får läsa', + 'settings.apiScopes.hint': + 'Lämna allt påslaget för en nyckel som ska se allt. Det du stänger av nekas för den här nyckeln, det utelämnas inte bara ur svaret.', + 'settings.apiScopes.all': 'Allt', + 'settings.apiScopes.limited': '{count} av {total}', + 'settings.apiScopes.trips': 'Resor', + 'settings.apiScopes.days': 'Dagar', + 'settings.apiScopes.places': 'Platser', + 'settings.apiScopes.notes': 'Dagsanteckningar', + 'settings.apiScopes.reservations': 'Bokningar', + 'settings.apiScopes.accommodations': 'Boende', + 'settings.apiScopes.travellers': 'Vilka som följer med', + 'settings.apiScopes.bucket-list': 'Önskelista', + 'settings.apiScopes.stats': 'Totalsummor', 'settings.apiKeys.title': 'API-nycklar', 'settings.apiKeys.description': 'Nycklar för det publika API:et, så att annan programvara kan läsa dina resor. Endast läsning: en nyckel kan inte ändra eller ta bort något.', 'settings.apiKeys.create': 'Skapa nyckel', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index d88a99bfc..38d769e3d 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -550,6 +550,21 @@ const settings: TranslationStrings = { 'Haritada her uçuş, tren ve diğer rezervasyonun rotasını, tek tek açmaya gerek kalmadan otomatik olarak gösterir.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Bu anahtar neleri okuyabilir', + 'settings.apiScopes.hint': + 'Her şeyi görmesi gereken bir anahtar için hepsini açık bırakın. Kapattığınız her şey bu anahtar için reddedilir, yalnızca yanıttan çıkarılmakla kalmaz.', + 'settings.apiScopes.all': 'Her şey', + 'settings.apiScopes.limited': '{total} içinden {count}', + 'settings.apiScopes.trips': 'Geziler', + 'settings.apiScopes.days': 'Günler', + 'settings.apiScopes.places': 'Yerler', + 'settings.apiScopes.notes': 'Gün notları', + 'settings.apiScopes.reservations': 'Rezervasyonlar', + 'settings.apiScopes.accommodations': 'Konaklama', + 'settings.apiScopes.travellers': 'Kimler geliyor', + 'settings.apiScopes.bucket-list': 'Dilek listesi', + 'settings.apiScopes.stats': 'Toplamlar', 'settings.apiKeys.title': 'API Anahtarları', 'settings.apiKeys.description': 'Genel API için anahtarlar, böylece başka yazılımlar gezilerini okuyabilir. Yalnızca okuma: bir anahtar hiçbir şeyi değiştiremez veya silemez.', 'settings.apiKeys.create': 'Anahtar oluştur', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 62bcc7e8a..4534bf99e 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -553,6 +553,21 @@ const settings: TranslationStrings = { 'Автоматично показує на карті маршрут для кожного рейсу, поїзда та іншого бронювання — без потреби вмикати це окремо для кожного елемента.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Що цей ключ може читати', + 'settings.apiScopes.hint': + 'Залиште все увімкненим для ключа, який має бачити геть усе. Усе, що ви вимкнете, для цього ключа буде заборонено, а не просто пропущено у відповіді.', + 'settings.apiScopes.all': 'Усе', + 'settings.apiScopes.limited': '{count} з {total}', + 'settings.apiScopes.trips': 'Подорожі', + 'settings.apiScopes.days': 'Дні', + 'settings.apiScopes.places': 'Місця', + 'settings.apiScopes.notes': 'Нотатки дня', + 'settings.apiScopes.reservations': 'Бронювання', + 'settings.apiScopes.accommodations': 'Проживання', + 'settings.apiScopes.travellers': 'Хто їде', + 'settings.apiScopes.bucket-list': 'Список бажань', + 'settings.apiScopes.stats': 'Підсумки', 'settings.apiKeys.title': 'Ключі API', 'settings.apiKeys.description': 'Ключі для публічного API, щоб інші програми могли читати ваші подорожі. Лише читання: ключ нічого не змінить і не видалить.', 'settings.apiKeys.create': 'Створити ключ', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index f3d2ad0d2..334cd3b31 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -554,6 +554,21 @@ const settings: TranslationStrings = { 'Tự động hiển thị trên bản đồ tuyến đường của mỗi chuyến bay, tàu hỏa và đặt chỗ khác, không cần bật riêng từng mục.', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': 'Khóa này được phép đọc gì', + 'settings.apiScopes.hint': + 'Hãy bật tất cả cho khóa cần xem được mọi thứ. Bất cứ mục nào bạn tắt sẽ bị từ chối với khóa này, chứ không chỉ là bị lược khỏi câu trả lời.', + 'settings.apiScopes.all': 'Tất cả', + 'settings.apiScopes.limited': '{count} trên {total}', + 'settings.apiScopes.trips': 'Chuyến đi', + 'settings.apiScopes.days': 'Ngày', + 'settings.apiScopes.places': 'Địa điểm', + 'settings.apiScopes.notes': 'Ghi chú theo ngày', + 'settings.apiScopes.reservations': 'Đặt chỗ', + 'settings.apiScopes.accommodations': 'Chỗ ở', + 'settings.apiScopes.travellers': 'Ai cùng đi', + 'settings.apiScopes.bucket-list': 'Danh sách mong muốn', + 'settings.apiScopes.stats': 'Tổng hợp', 'settings.apiKeys.title': 'Khóa API', 'settings.apiKeys.description': 'Khóa cho API công khai, để phần mềm khác có thể đọc các chuyến đi của bạn. Chỉ đọc: khóa không thể thay đổi hay xóa bất cứ thứ gì.', 'settings.apiKeys.create': 'Tạo khóa', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 6c2f6e5e4..2fc286d64 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -517,6 +517,21 @@ const settings: TranslationStrings = { 'settings.alwaysShowRoutesHint': '自動在地圖上顯示每個航班、火車及其他預訂的路線,不需要逐一手動開啟。', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': '這組金鑰可讀取的內容', + 'settings.apiScopes.hint': + '若希望這組金鑰能看到全部內容,就全部保持開啟。任何你關閉的項目都會對這組金鑰直接拒絕,而不只是從回應中省略。', + 'settings.apiScopes.all': '全部', + 'settings.apiScopes.limited': '{total} 項中的 {count} 項', + 'settings.apiScopes.trips': '旅行', + 'settings.apiScopes.days': '日期', + 'settings.apiScopes.places': '地點', + 'settings.apiScopes.notes': '每日備註', + 'settings.apiScopes.reservations': '訂位', + 'settings.apiScopes.accommodations': '住宿', + 'settings.apiScopes.travellers': '同行者', + 'settings.apiScopes.bucket-list': '願望清單', + 'settings.apiScopes.stats': '總計', 'settings.apiKeys.title': 'API 金鑰', 'settings.apiKeys.description': '公開 API 的金鑰,讓其他軟體讀取你的行程。唯讀:金鑰無法修改或刪除任何內容。', 'settings.apiKeys.create': '建立金鑰', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index a662db141..93df25e5c 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -513,6 +513,21 @@ const settings: TranslationStrings = { 'settings.alwaysShowRoutesHint': '自动在地图上显示每个航班、火车和其他预订的路线,无需逐个手动开启。', // Public API keys (Settings -> Integrations) + // ── API keys: what a key may read ───────────────────────────────────────── + 'settings.apiScopes.title': '此密钥可读取的内容', + 'settings.apiScopes.hint': + '如果希望这个密钥看到全部内容,就保持全部开启。任何被关闭的内容对该密钥都会被拒绝,而不只是不出现在结果里。', + 'settings.apiScopes.all': '全部', + 'settings.apiScopes.limited': '{total} 项中的 {count} 项', + 'settings.apiScopes.trips': '行程', + 'settings.apiScopes.days': '日程', + 'settings.apiScopes.places': '地点', + 'settings.apiScopes.notes': '每日备注', + 'settings.apiScopes.reservations': '预订', + 'settings.apiScopes.accommodations': '住宿', + 'settings.apiScopes.travellers': '同行人员', + 'settings.apiScopes.bucket-list': '愿望清单', + 'settings.apiScopes.stats': '统计总计', 'settings.apiKeys.title': 'API 密钥', 'settings.apiKeys.description': '公开 API 的密钥,让其他软件读取你的行程。只读:密钥无法修改或删除任何内容。', 'settings.apiKeys.create': '创建密钥', diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts index 534b17875..8c92e372e 100644 --- a/shared/src/public-api/public-api.schema.ts +++ b/shared/src/public-api/public-api.schema.ts @@ -206,6 +206,82 @@ export const publicApiTripListSchema = z.object({ }); export type PublicApiTripList = z.infer; +/** + * What an integration key is allowed to read. + * + * These are **not** `include`. `include` picks which sections a response + * carries and any caller may ask for any of them; these decide what the key may + * see at all, and the server refuses the rest. The two were confused often + * enough that it is worth saying plainly: handing an integration a key because + * it wanted day notes used to hand it every trip in full. + * + * The sections mirror the `include` vocabulary so an integrator does not have + * to learn two lists, plus the two endpoints that are not a trip section: + * `bucket-list` (hangs off the user) and `stats` (aggregates everything, which + * is exactly why a narrow key must be able to exclude it). + */ +export const PUBLIC_API_SCOPES = [ + 'trips', + 'days', + 'places', + 'notes', + 'reservations', + 'accommodations', + 'travellers', + 'bucket-list', + 'stats', +] as const; +export type PublicApiScope = (typeof PUBLIC_API_SCOPES)[number]; + +/** + * How a key's access is decided. + * + * `all` is an explicit value rather than an absent list, because "no list means + * everything" is the kind of sentinel where a bug that drops the column grants + * full access instead of none. Every key minted before scopes existed is `all`, + * which is exactly what it was and still is. + */ +export const PUBLIC_API_SCOPE_MODES = ['all', 'limited'] as const; +export const publicApiScopeModeSchema = z.enum(PUBLIC_API_SCOPE_MODES); +export type PublicApiScopeMode = z.infer; + +/** + * What a key may read, resolved. + * + * `mode: 'all'` carries the full list rather than an empty one, so a consumer + * never has to reimplement "empty means everything" — the rule this type exists + * to get rid of. + */ +export const publicApiGrantSchema = z.object({ + mode: publicApiScopeModeSchema, + scopes: z.array(z.enum(PUBLIC_API_SCOPES)), +}); +export type PublicApiGrant = z.infer; + +/** + * Minting an integration key. A key created without `scopes` reads everything, + * which is what every key did before this existed — narrowing is opt-in, so no + * integration breaks the day the column ships. + */ +export const apiTokenCreateRequestSchema = z.object({ + name: z.string().optional(), + scopes: z.array(z.enum(PUBLIC_API_SCOPES)).min(1).optional(), +}); +export type ApiTokenCreateRequest = z.infer; + +/** A key as the settings list shows it. The key itself is never in here. */ +export const apiTokenSummarySchema = z.object({ + id: z.number(), + name: z.string(), + token_prefix: z.string(), + created_at: z.string(), + last_used_at: z.string().nullable(), + scope_mode: publicApiScopeModeSchema, + /** The chosen sections; the full list when the mode is `all`. */ + scopes: z.array(z.enum(PUBLIC_API_SCOPES)), +}); +export type ApiTokenSummary = z.infer; + /** * The trip the traveller most recently took, for a dashboard that wants to name it. * diff --git a/wiki/Public-API.md b/wiki/Public-API.md index c10571cad..4cf2baf3d 100644 --- a/wiki/Public-API.md +++ b/wiki/Public-API.md @@ -19,7 +19,8 @@ It cannot write anything. An integration that reads your itinerary needs no abil 1. Open **Settings → Integrations** 2. Under **API Keys**, click **Create key** and give it a name you will recognise later (the name is only for you — "Dawarich", "home assistant", "laptop script") -3. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one. +3. Choose **what the key may read.** Everything is selected by default. Anything you switch off is refused for that key. +4. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one. Keys look like `trek_` followed by 48 characters. You can hold ten at a time. @@ -45,6 +46,21 @@ curl -H "X-API-Key: trek_your_key_here" \ Both are equivalent. Use whichever the other side offers. +## What a key may read + +A key can be narrowed when you create it, to any subset of: + +`trips` · `days` · `places` · `notes` · `reservations` · `accommodations` · `travellers` · `bucket-list` · `stats` + +This is **not** the same as `include`. `include` chooses which sections a response carries and any key may ask for any of them; this decides what the key may see at all, and the server refuses the rest with a `403`. A key given only `days` and `notes` reads exactly that, however it asks. + +Two rules make it predictable: + +- **A section you named but may not read is a `403`**, with the missing one in `required_scope`. Silently dropping what you explicitly asked for would send you debugging your own correct code. +- **A section you did not name is simply left out.** `include` defaults to everything, so a narrow key that omits `include` gets what it may have rather than an error for wanting sections it never mentioned. + +Keys created before this existed, and keys created with everything selected, read all of it — nothing that worked before stops working. + ## Endpoints ### `GET /api/v1/trips` @@ -220,6 +236,7 @@ A Homepage widget then needs no scripting: |---|---| | `400` | malformed trip id, or an unknown `include` section | | `401` | missing, malformed or unknown key — also what you get for a key of the wrong kind | +| `403` | the key is valid but not allowed to read what was asked for (`code: "API_SCOPE_FORBIDDEN"`, with the missing section in `required_scope`) | | `404` | no such trip, or not one of yours | | `429` | rate limit exceeded | @@ -227,7 +244,7 @@ A Homepage widget then needs no scripting: ## What is not here yet -- **Writing.** Read-only for now. Write access needs per-scope enforcement that this surface does not implement, and a key that only reads is a much safer thing to hand out. +- **Writing.** Read-only. A key that can only read is a very different thing to hand to third-party software, and every integration asking for this surface so far wants to read. - **Incremental sync.** There is no `?since=` filter, because TREK cannot yet answer it truthfully — child records carry no modification timestamp, so a filter on `updated_at` would silently hide trips whose itinerary changed. Fetch the list and compare. - **Webhooks.** Nothing pushes; poll at a sensible interval. From 218d8baf0a56bf0edd5922b82e9df3ad2693149f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:34:54 +0200 Subject: [PATCH 3/7] feat(server): read visits and recorded routes from Dawarich A new `integrations/dawarich` domain behind its own addon. Each reader connects their own instance; the API key is encrypted at rest and never returned. Nothing is written back to Dawarich, and no raw position is kept in TREK: the recorded route is fetched per request for the window being looked at and thrown away again. What it produces are suggestions, never facts. A recorded stay becomes a proposed place or journal entry, a country or city becomes a proposed Atlas mark, and a stay near a wish becomes a proposed tick-off; each one waits for the reader to confirm it. Identity is (user, Dawarich visit), so a second sync neither duplicates nor overwrites what was accepted, and deleting the result puts the suggestion back on the list. The sync runs as a job on the central scheduler, one user at a time, guarded against overlapping runs. --- server/scripts/migrate-encryption.ts | 16 + server/src/addons.ts | 2 + server/src/db/migrations.ts | 111 ++ server/src/db/seeds.ts | 1 + server/src/nest/admin/admin.service.ts | 8 +- server/src/nest/app.module.ts | 3 +- server/src/nest/atlas/atlas.service.ts | 71 +- .../src/nest/atlas/public-stats.controller.ts | 12 +- server/src/nest/auth/auth.controller.ts | 8 +- server/src/nest/auth/auth.dto.ts | 7 + .../dawarich-suggestions.service.ts | 734 +++++++++++++ .../nest/integrations/dawarich-sync.job.ts | 47 + .../integrations/dawarich-sync.service.ts | 525 ++++++++++ .../integrations/dawarich-tracks.service.ts | 194 ++++ .../src/nest/integrations/dawarich.client.ts | 426 ++++++++ .../nest/integrations/dawarich.controller.ts | 351 +++++++ server/src/nest/integrations/dawarich.dto.ts | 19 + .../src/nest/integrations/dawarich.helpers.ts | Bin 0 -> 19215 bytes server/src/nest/integrations/dawarich.mcp.ts | 240 +++++ .../src/nest/integrations/dawarich.module.ts | 66 ++ .../src/nest/integrations/dawarich.service.ts | 401 +++++++ server/src/typings/express.d.ts | 11 + server/tests/e2e/dawarich.e2e.test.ts | 345 ++++++ server/tests/helpers/test-db.ts | 4 + server/tests/unit/nest/atlas.service.test.ts | 16 +- .../nest/dawarich-suggestions.service.test.ts | 989 ++++++++++++++++++ .../unit/nest/dawarich-sync.service.test.ts | 828 +++++++++++++++ .../tests/unit/nest/dawarich.client.test.ts | 540 ++++++++++ .../unit/nest/dawarich.controller.test.ts | 584 +++++++++++ .../tests/unit/nest/dawarich.helpers.test.ts | 858 +++++++++++++++ .../unit/nest/public-stats.controller.test.ts | 10 +- server/vitest.config.ts | 2 +- shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh/admin.ts | 3 + 55 files changed, 7469 insertions(+), 29 deletions(-) create mode 100644 server/src/nest/integrations/dawarich-suggestions.service.ts create mode 100644 server/src/nest/integrations/dawarich-sync.job.ts create mode 100644 server/src/nest/integrations/dawarich-sync.service.ts create mode 100644 server/src/nest/integrations/dawarich-tracks.service.ts create mode 100644 server/src/nest/integrations/dawarich.client.ts create mode 100644 server/src/nest/integrations/dawarich.controller.ts create mode 100644 server/src/nest/integrations/dawarich.dto.ts create mode 100644 server/src/nest/integrations/dawarich.helpers.ts create mode 100644 server/src/nest/integrations/dawarich.mcp.ts create mode 100644 server/src/nest/integrations/dawarich.module.ts create mode 100644 server/src/nest/integrations/dawarich.service.ts create mode 100644 server/tests/e2e/dawarich.e2e.test.ts create mode 100644 server/tests/unit/nest/dawarich-suggestions.service.test.ts create mode 100644 server/tests/unit/nest/dawarich-sync.service.test.ts create mode 100644 server/tests/unit/nest/dawarich.client.test.ts create mode 100644 server/tests/unit/nest/dawarich.controller.test.ts create mode 100644 server/tests/unit/nest/dawarich.helpers.test.ts diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index 4c13c431b..4b4e2559e 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -530,6 +530,22 @@ async function main() { } } + // --- dawarich_connections: api_key --- + // Its own table rather than a users column, so it needs its own block. The + // table arrived with a later migration, hence the existence check: an older + // database simply has nothing to rotate here. + if (tableExists('dawarich_connections')) { + const connections = db + .prepare('SELECT user_id, api_key FROM dawarich_connections WHERE api_key IS NOT NULL') + .all() as { user_id: number; api_key: string }[]; + for (const row of connections) { + const newVal = migrateApiKeyValue(row.api_key, `dawarich_connections[${row.user_id}].api_key`); + if (newVal !== null) { + db.prepare('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?').run(newVal, row.user_id); + } + } + } + // --- trek_photos: passphrase --- const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[]; for (const row of photos) { diff --git a/server/src/addons.ts b/server/src/addons.ts index 2f72c50fa..14c897f8d 100644 --- a/server/src/addons.ts +++ b/server/src/addons.ts @@ -8,6 +8,7 @@ export const ADDON_IDS = { COLLAB: 'collab', JOURNEY: 'journey', AIRTRAIL: 'airtrail', + DAWARICH: 'dawarich', LLM_PARSING: 'llm_parsing', COLLECTIONS: 'collections', ROADTRIP: 'roadtrip', @@ -40,6 +41,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [ ADDON_IDS.BUDGET, ADDON_IDS.COLLAB, ADDON_IDS.COLLECTIONS, + ADDON_IDS.DAWARICH, ADDON_IDS.JOURNEY, ADDON_IDS.PACKING, ADDON_IDS.ROADTRIP, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 0ad56fec7..60e267bb1 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4620,6 +4620,117 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT'); } }, + // Dawarich connection (#2279). Its own table rather than more `users` columns, + // which is where the AirTrail connection lives: that was the right call while + // AirTrail was the only integration of this shape, and this is the second. It + // also carries sync state (cursor, last error, probed capabilities) that has no + // business sitting on the identity row. Credentials for a personal location + // archive stay strictly apart from anything shared on a trip. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS dawarich_connections ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + url TEXT, + api_key TEXT, + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + capabilities TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + }, + // Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — + // the UNIQUE is what makes a repeated sync idempotent instead of duplicating + // everything it already imported. + // + // `source_hash` exists because a Dawarich visit carries no `updated_at`: a + // rename or a re-detection is only visible as a different hash of the fields + // TREK shows. `source_missing_at` exists because deleting a visit removes it + // from the API rather than tombstoning it, so absence from a full re-read of + // the same window is the only signal — and it is recorded, not acted on, + // because an entry the user already accepted and edited is theirs now. + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_visit_id TEXT NOT NULL, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 0, + local_date TEXT NOT NULL, + source_status TEXT NOT NULL DEFAULT 'suggested', + confidence REAL, + confidence_band TEXT, + country_code TEXT, + state TEXT NOT NULL DEFAULT 'new', + target TEXT, + accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + accepted_journal_entry_id INTEGER, + accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + source_hash TEXT NOT NULL, + accepted_hash TEXT, + source_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_visit_id) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)'); + }, + // Bucket-list entries can now be ticked off (#2279). `visited_source` records + // who decided — a hand-ticked wish and one confirmed from a recording read the + // same on the map otherwise, and re-running a scan must not touch the first. + () => { + const hasVisitedAt = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_at'").get(); + if (!hasVisitedAt) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_at TEXT'); + const hasVisitedSource = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_source'").get(); + if (!hasVisitedSource) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_source TEXT'); + }, + // Where an Atlas country came from (#2279). Everything that exists today was + // marked by hand, so 'manual' is the correct backfill rather than a guess; + // countries confirmed out of a recording are written as 'dawarich' and the + // Atlas can say so. Unmarking still deletes the row either way. + () => { + const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('visited_countries') WHERE name = 'source'").get(); + if (!hasSource) db.exec("ALTER TABLE visited_countries ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'"); + }, + // Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which + // sections a *response* carries; it has never restricted what a key may read, + // and handing an integration a key that reads every trip because it wanted day + // notes is the thing that needed fixing. + // + // `scope_mode` is an explicit flag rather than "NULL means everything": a + // sentinel here would mean a bug that drops the scopes column silently grants + // full access. Every existing key is 'all', so nothing that works today stops + // working — the restriction is opt-in at mint time. + () => { + const hasMode = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'scope_mode'").get(); + if (!hasMode) db.exec("ALTER TABLE mcp_tokens ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'all'"); + const hasScopes = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'api_scopes'").get(); + if (!hasScopes) db.exec('ALTER TABLE mcp_tokens ADD COLUMN api_scopes TEXT'); + }, + // Where a place came from, when it did not come from a person typing it + // (#2279). Only 'dawarich' writes it today; NULL is every place anyone has + // ever added by hand, which is what it should stay. + // + // A column rather than a lookup through dawarich_visit_suggestions: the mark + // has to survive the suggestion being deleted, and a place list would + // otherwise join an integration's table to render a name. + () => { + const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get(); + if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 8ad4ff046..9ef273e78 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -123,6 +123,7 @@ function seedAddons(db: Database.Database): void { { id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 }, { id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 }, { id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 }, + { id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 }, { id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 }, { id: 'collections', name: 'Collections', description: 'Personal place library — save places across trips into named lists, copy into any trip, share with others', type: 'global', icon: 'Bookmark', enabled: 0, sort_order: 16 }, ]; diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts index 5e3677f7e..349337bee 100644 --- a/server/src/nest/admin/admin.service.ts +++ b/server/src/nest/admin/admin.service.ts @@ -548,13 +548,15 @@ export class AdminService { // to the operator, so there is no instance decision left to make — not even // whether it runs. // - // AirTrail: the addon exists to reach a server the admin runs themselves, - // and a managed instance has no route to one. + // AirTrail and Dawarich: both addons exist to reach a server the admin + // runs themselves, and a managed instance has no route to one. .filter( (a) => !( readEnv().managed.enabled && - (a.id === ADDON_IDS.LLM_PARSING || a.id === ADDON_IDS.AIRTRAIL) + (a.id === ADDON_IDS.LLM_PARSING || + a.id === ADDON_IDS.AIRTRAIL || + a.id === ADDON_IDS.DAWARICH) ), ); const providers = this.db.all<{ diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index d9a77caa3..966e2bba4 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -44,6 +44,7 @@ import { FilesModule } from './files/files.module'; import { PhotosModule } from './photos/photos.module'; import { MemoriesModule } from './memories/memories.module'; import { AirtrailModule } from './integrations/airtrail.module'; +import { DawarichModule } from './integrations/dawarich.module'; import { JourneyModule } from './journey/journey.module'; import { CollectionsModule } from './collections/collections.module'; import { ShareModule } from './share/share.module'; @@ -80,7 +81,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module'; * migrated. */ @Module({ - imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], + imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule], providers: [ // Default-deny: a route is authenticated unless it carries @Public() or // @OptionalAuth(), or declares its own @UseGuards chain. Protection used to diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts index 018fc1888..94fe8c18d 100644 --- a/server/src/nest/atlas/atlas.service.ts +++ b/server/src/nest/atlas/atlas.service.ts @@ -82,6 +82,19 @@ function blankToNull(value: string | null | undefined): string | null { return value === undefined || value === null || value === '' ? null : value; } +/** + * Who ticked a country off, for the detail sheet. + * + * Null when there is no row at all — a country derived from a trip's places was + * never marked by anyone, and reporting 'manual' for it would claim a decision + * nobody made. A row written before the column existed defaults to 'manual', + * which is exactly what it was. + */ +function markedSource(row: { source?: string | null } | undefined): string | null { + if (!row) return null; + return row.source || 'manual'; +} + /** * DI-native atlas domain service — the legacy services/atlasService SQL * (stats aggregation, visited countries/regions with the #1490 tombstones, @@ -469,10 +482,17 @@ export class AtlasService { if (tripIds.length === 0) { // Post-fold quirk fix: the legacy early return hardcoded manually_marked // false, so a trip-less user's manually marked country read as unmarked. - const marked = !!this.db - .prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?') - .get(userId, code); - return { places: [], trips: [], manually_marked: marked, status: marked ? 'visited' : 'idea' }; + const row = this.db + .prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?') + .get(userId, code) as { source?: string | null } | undefined; + const marked = !!row; + return { + places: [], + trips: [], + manually_marked: marked, + marked_source: markedSource(row), + status: marked ? 'visited' : 'idea', + }; } const places = this.getPlacesForTrips(tripIds); @@ -506,9 +526,10 @@ export class AtlasService { .filter((t) => matchingTripIds.has(t.id)) .map((t) => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date })); - const isManuallyMarked = !!this.db - .prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?') - .get(userId, code); + const markRow = this.db + .prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?') + .get(userId, code) as { source?: string | null } | undefined; + const isManuallyMarked = !!markRow; // Take the status from the same trip classification stats() uses rather than deriving // it again here — the detail sheet and the map must agree on what this country is. @@ -517,15 +538,24 @@ export class AtlasService { for (const id of matchingTripIds) status = strongerVisitStatus(status, tripStatus.get(id) ?? 'idea'); if (isManuallyMarked) status = 'visited'; - return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked, status }; + return { + places: matchingPlaces, + trips: matchingTrips, + manually_marked: isManuallyMarked, + // Where the tick came from, so the sheet can say "confirmed from your + // Dawarich recordings" instead of implying the user typed it in. Null when + // the country is only derived from trips and carries no row at all. + marked_source: markedSource(markRow), + status, + }; } // ── Mark / unmark country ───────────────────────────────────────────────── - listVisitedCountries(userId: number): { country_code: string; created_at: string }[] { + listVisitedCountries(userId: number): { country_code: string; created_at: string; source: string }[] { return this.db - .prepare('SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC') - .all(userId) as { country_code: string; created_at: string }[]; + .prepare('SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC') + .all(userId) as { country_code: string; created_at: string; source: string }[]; } /** Countries the user explicitly removed, which stats() must not re-derive (#1490). */ @@ -536,11 +566,24 @@ export class AtlasService { return new Set(rows.map((r) => r.country_code)); } - markCountry(userId: number, code: string): void { - this.db.transaction(() => { - this.db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code); + /** + * `source` records who decided. It defaults to 'manual' because that is what + * every mark was until an integration could make one, and `INSERT OR IGNORE` + * means a country already ticked by hand keeps that provenance — confirming + * it again from a recording does not relabel somebody's own work as imported. + */ + /** + * Returns whether this actually added the country, so a caller reporting + * "3 countries added" is not counting the ones that were already there. + */ + markCountry(userId: number, code: string, source: 'manual' | 'dawarich' = 'manual'): boolean { + return this.db.transaction(() => { + const inserted = this.db + .prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code, source) VALUES (?, ?, ?)') + .run(userId, code, source).changes > 0; // Marking it visited again lifts a previous removal. this.db.prepare('DELETE FROM hidden_countries WHERE user_id = ? AND country_code = ?').run(userId, code); + return inserted; }); } diff --git a/server/src/nest/atlas/public-stats.controller.ts b/server/src/nest/atlas/public-stats.controller.ts index ea704a883..d4317d8ee 100644 --- a/server/src/nest/atlas/public-stats.controller.ts +++ b/server/src/nest/atlas/public-stats.controller.ts @@ -3,7 +3,7 @@ import type { Request } from 'express'; import type { PublicApiStats } from '@trek/shared'; import { AtlasService } from './atlas.service'; import { ApiTokenGuard } from '../public-api/api-token.guard'; -import { enforcePublicApiRateLimit, requireUserId } from '../public-api/public-api-request'; +import { enforcePublicApiRateLimit, requireScope, requireUserId } from '../public-api/public-api-request'; import { RateLimitService } from '../common/rate-limit.service'; /** @@ -30,9 +30,12 @@ import { RateLimitService } from '../common/rate-limit.service'; * instead. TravelStatsController next door already does exactly this for * `/api/auth/travel-stats`, and for the same reason. * - * What crosses the boundary is only the request handling: the guard and the shared - * rate-limit bucket, so an integration polling `/trips` and `/stats` spends one - * budget rather than two. + * What crosses the boundary is only the request handling: the guard, the shared + * rate-limit bucket (so an integration polling `/trips` and `/stats` spends one + * budget rather than two) and the key's read scopes. The scope check is not + * optional here: this route aggregates trips, countries, cities and flown + * distance, which is precisely the reach a narrowed key exists to withhold, and + * enforcing scopes only in public-api/ would leave the widest answer open. */ @Controller('api/v1') @UseGuards(ApiTokenGuard) @@ -45,6 +48,7 @@ export class PublicStatsController { @Get('stats') stats(@Req() req: Request): PublicApiStats { enforcePublicApiRateLimit(this.rl, req); + requireScope(req, 'stats'); const userId = requireUserId(req); const travel = this.atlas.getTravelStats(userId); diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts index 0e1967f46..f52b214ee 100644 --- a/server/src/nest/auth/auth.controller.ts +++ b/server/src/nest/auth/auth.controller.ts @@ -33,6 +33,7 @@ import { MfaEnableDto, MfaDisableDto, McpTokenCreateDto, + ApiTokenCreateDto, ResourceTokenDto, } from './auth.dto'; import { RateLimitService } from '../common/rate-limit.service'; @@ -340,9 +341,12 @@ export class AuthController { @Post('api-tokens') @HttpCode(201) - createApiToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) { + createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) { this.limit('login', req, 5); - const result = this.tokens.createApiToken(user.id, body.name); + // No `scopes` means the key reads everything, which is what every key minted + // before this field existed does. Narrowing stays opt-in so the change + // cannot break an integration that is already running. + const result = this.tokens.createApiToken(user.id, body.name, body.scopes); if (result.error) { throw new HttpException({ error: result.error }, result.status!); } diff --git a/server/src/nest/auth/auth.dto.ts b/server/src/nest/auth/auth.dto.ts index 2f806c855..876af5111 100644 --- a/server/src/nest/auth/auth.dto.ts +++ b/server/src/nest/auth/auth.dto.ts @@ -9,6 +9,7 @@ import { mfaEnableRequestSchema, mfaDisableRequestSchema, mcpTokenCreateRequestSchema, + apiTokenCreateRequestSchema, mapsKeyUpdateRequestSchema, apiKeysUpdateRequestSchema, settingsUpdateRequestSchema, @@ -38,6 +39,12 @@ export class MfaVerifyLoginDto extends createZodDto(mfaVerifyLoginRequestSchema) export class MfaEnableDto extends createZodDto(mfaEnableRequestSchema) {} export class MfaDisableDto extends createZodDto(mfaDisableRequestSchema) {} export class McpTokenCreateDto extends createZodDto(mcpTokenCreateRequestSchema) {} +/** + * Its own DTO rather than a field added to the one above: both create routes + * share that schema, and widening it would make POST /api/auth/mcp-tokens + * quietly accept read scopes that an MCP token has no way to honour. + */ +export class ApiTokenCreateDto extends createZodDto(apiTokenCreateRequestSchema) {} export class MapsKeyUpdateDto extends createZodDto(mapsKeyUpdateRequestSchema) {} export class ApiKeysUpdateDto extends createZodDto(apiKeysUpdateRequestSchema) {} export class SettingsUpdateDto extends createZodDto(settingsUpdateRequestSchema) {} diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts new file mode 100644 index 000000000..57b50fdc8 --- /dev/null +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -0,0 +1,734 @@ +import { Injectable } from '@nestjs/common'; +import { + DAWARICH_BUCKET_MATCH_MIN_MINUTES, + DAWARICH_BUCKET_MATCH_RADIUS_M, + DAWARICH_BUCKET_SCAN_LIMIT, + type DawarichAccept, + type DawarichAcceptResult, + type DawarichAtlasCountry, + type DawarichAtlasSuggestions, + type DawarichBucketMatch, + type DawarichBucketScan, + type DawarichSuggestion, + type DawarichSuggestionList, +} from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; +import { AtlasService } from '../atlas/atlas.service'; +import { PlacesService } from '../places/places.service'; +import { AssignmentsService } from '../assignments/assignments.service'; +import { PermissionsService } from '../permissions/permissions.service'; +import { JourneyDomainService } from '../journey/journey-domain.service'; +import { NAME_TO_CODE } from '../atlas/atlas-geo'; +import { DawarichClient, type DawarichCreds } from './dawarich.client'; +import { DawarichService } from './dawarich.service'; +import { minutesBetween, toNumber } from './dawarich.helpers'; + +/** + * What a user does with the stays a sync brought in. + * + * Everything here is an explicit act by the person the suggestion belongs to. + * That is the whole point of the split: the sync may look at a shared trip, but + * only this service is ever allowed to write to one, and only because somebody + * pressed something. + * + * Three destinations, deliberately not one: + * - **a place on the trip**, for a stay that was a stop worth planning around; + * - **a journal entry**, for one that was a moment worth writing about; + * - **a bucket-list tick**, for one that fulfilled a wish. + * They are separate because accepting into the wrong one is a mess to undo, and + * a single "import" button would have to guess which was meant. + * + * The trip UI offers the first two. A wish belongs to the Atlas — it is reached + * once, and it is confirmed there, against the whole wishlist at once — so the + * third is reached from the Atlas and from MCP, not from a row in a trip's rail. + */ +@Injectable() +export class DawarichSuggestionsService { + constructor( + private readonly db: DatabaseService, + private readonly dawarich: DawarichService, + private readonly client: DawarichClient, + private readonly atlas: AtlasService, + private readonly places: PlacesService, + private readonly assignments: AssignmentsService, + private readonly permissions: PermissionsService, + private readonly journey: JourneyDomainService, + ) {} + + /** + * The review list, newest stay first. + * + * Scoped by `user_id` in SQL rather than filtered afterwards: these rows are + * someone's location history, and a forgotten filter over a wider read is how + * that leaks. + */ + list(userId: number, filter: { tripId?: number; state?: string }): DawarichSuggestionList { + this.reopenOrphaned(userId); + + const clauses = ['s.user_id = ?']; + const params: unknown[] = [userId]; + if (filter.tripId !== undefined) { + clauses.push('s.trip_id = ?'); + params.push(filter.tripId); + } + if (filter.state) { + clauses.push('s.state = ?'); + params.push(filter.state); + } + + const rows = this.db.all( + `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name + FROM dawarich_visit_suggestions s + LEFT JOIN trips t ON t.id = s.trip_id + LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id + WHERE ${clauses.join(' AND ')} + ORDER BY s.started_at DESC, s.id DESC`, + ...params, + ); + + const connection = this.dawarich.getConnection(userId); + return { + suggestions: rows.map(toWire), + connected: connection.connected, + lastSyncAt: connection.lastSyncAt, + lastSyncState: connection.lastSyncState, + lastSyncError: connection.lastSyncError, + }; + } + + /** + * Put back into review every acceptance whose result no longer exists. + * + * Deleting the place an acceptance created is how somebody says "not that + * one" after the fact, and the stay then has nothing to show for itself: it + * is unhandled again, and leaving it in the handled list is a dead end — the + * row points at a place that is gone and accepting it again is refused as a + * duplicate. + * + * The place and bucket columns are `ON DELETE SET NULL`, so a NULL beside a + * target that names them IS the deletion. Journey entries are written by + * another domain without a foreign key, so that one is asked directly. + * + * A write on a read path, deliberately: the alternative is computing the state + * at read time, which would make the list say "back in review" while `accept` + * still refused the row as already accepted. + */ + private reopenOrphaned(userId: number): void { + this.db.run( + `UPDATE dawarich_visit_suggestions + SET state = 'new', target = NULL, accepted_hash = NULL, + accepted_place_id = NULL, accepted_journal_entry_id = NULL, + accepted_bucket_list_item_id = NULL + WHERE user_id = ? AND state = 'accepted' + AND ( + (target = 'place' AND accepted_place_id IS NULL) + OR (target = 'bucket_list' AND accepted_bucket_list_item_id IS NULL) + OR (target = 'journal' AND ( + accepted_journal_entry_id IS NULL + OR NOT EXISTS ( + SELECT 1 FROM journey_entries je WHERE je.id = accepted_journal_entry_id + ) + )) + )`, + userId, + ); + } + + getOne(userId: number, id: number): DawarichSuggestion | null { + const row = this.db.get( + `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name + FROM dawarich_visit_suggestions s + LEFT JOIN trips t ON t.id = s.trip_id + LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id + WHERE s.id = ? AND s.user_id = ?`, + id, + userId, + ); + return row ? toWire(row) : null; + } + + /** + * Put a suggestion back into review, or take it out of the list. + * + * Reversible on purpose: dismissing is the cheap action people take to clear + * a backlog, and the one they regret. Restoring an *accepted* suggestion is + * not offered — the place or entry it produced is a separate thing now, and + * "undo" would have to guess whether to delete it. + */ + setState(userId: number, id: number, state: 'new' | 'dismissed'): DawarichSuggestion | null { + const row = this.db.get<{ id: number; state: string }>( + 'SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?', + id, + userId, + ); + if (!row) return null; + if (row.state === 'accepted') return this.getOne(userId, id); + + this.db.run('UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?', state, id); + return this.getOne(userId, id); + } + + /** + * Accept a suggestion into TREK. + * + * Throws `AcceptError` rather than an HttpException so the controller, the + * MCP tool and any future caller each shape the failure their own way — the + * atlas domain's `BucketItemExistsError` is the precedent. + */ + accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult { + const row = this.db.get( + 'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?', + id, + userId, + ); + if (!row) throw new AcceptError('not_found', 'Suggestion not found', 404); + if (row.state === 'accepted') throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409); + + switch (body.target) { + case 'place': + return this.acceptAsPlace(userId, row, body, sid); + case 'journal': + return this.acceptAsJournalEntry(userId, row, body, sid); + case 'bucket_list': + return this.acceptAsBucketTick(userId, row, body); + } + } + + /** + * A stay becomes a place on a trip, optionally pinned to one of its days. + * + * Every referenced id is verified against the trip before anything is + * written: permission to touch the trip is not permission to attach a place + * to somebody else's day. + */ + private acceptAsPlace( + userId: number, + row: SuggestionRow, + body: DawarichAccept, + sid?: string, + ): DawarichAcceptResult { + const tripId = body.tripId ?? row.trip_id; + if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400); + + const access = this.db.canAccessTrip(tripId, userId); + if (!access) throw new AcceptError('not_found', 'Trip not found', 404); + + // Being on the roster is not permission to write to it. Every other way of + // putting a place on a trip asks `place_edit` (and `day_edit` for the + // assignment), and on an instance where an admin narrowed either to the + // owner, a member accepting a stay must be refused exactly as they would be + // in the planner. Asked here rather than in the controller so the MCP tool + // cannot take a different route to the same write. + this.requirePermission('place_edit', access.user_id, userId); + + if (body.dayId !== undefined) { + this.requirePermission('day_edit', access.user_id, userId); + if (!this.assignments.dayExists(body.dayId, tripId)) { + throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400); + } + } + + const lat = body.lat ?? row.lat ?? undefined; + const lng = body.lng ?? row.lng ?? undefined; + + // One transaction for the four writes an acceptance makes. Half of it — + // a place with no assignment, or a place nobody recorded as accepted — is + // worse than none: the stay would come back as unhandled while the place it + // already produced sat on the trip. + const { created, placeId, assignment } = this.db.transaction(() => { + const place = this.places.create(String(tripId), { + name: body.name?.trim() || row.name, + lat, + lng, + notes: body.notes ?? null, + place_time: body.time ?? timeOf(row.started_at), + end_time: body.endTime ?? timeOf(row.ended_at), + duration_minutes: row.duration_minutes > 0 ? row.duration_minutes : undefined, + } as Parameters[1]); + + const id = Number((place as { id: number }).id); + // Stamped here rather than passed through PlacesService.create: `source` + // is not part of the create contract and should not become something a + // request body can set — a place claims to come from a recording only + // because this path made it. + this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id); + + const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined; + this.markAccepted(row.id, 'place', { placeId: id }); + // Re-read, so what goes out on the wire carries the source: everyone else + // on the trip should see the Dawarich mark on it too, not only the person + // who accepted it. + return { created: this.db.getPlaceWithTags(id) ?? place, placeId: id, assignment: day }; + }); + + // Outside the transaction, because both reach past the database: a + // broadcast of a write that then rolls back is a lie every other client + // believes, and the journey mirror writes on its own. + // + // The same two side effects the places controller performs, because this IS + // a place somebody added to the trip. Skipping them is why an accepted stay + // used to need a page reload to show up. + this.places.broadcast(String(tripId), 'place:created', { place: created }, sid); + this.places.onCreated(String(tripId), placeId); + if (assignment) { + this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, sid); + } + + return { + suggestion: this.getOne(userId, row.id)!, + createdPlaceId: placeId, + createdJournalEntryId: null, + bucketListItemId: null, + }; + } + + /** + * A stay becomes a dated journal entry. + * + * The date comes from the stay's own local date unless corrected, and the + * arrival time fills the entry's time — the two fields a person would + * otherwise copy off the suggestion by hand. + */ + private acceptAsJournalEntry( + userId: number, + row: SuggestionRow, + body: DawarichAccept, + sid?: string, + ): DawarichAcceptResult { + if (body.journalId === undefined) { + throw new AcceptError('journal_required', 'A journey is required to create an entry', 400); + } + // canEdit is the journey domain's own answer, and createEntry asks it again. + // Asking here too is what turns "silently did nothing" into a 403. + if (!this.journey.canEdit(body.journalId, userId)) { + throw new AcceptError('journal_forbidden', 'Journey not found', 404); + } + + // The entry and the record that it was written commit together. Apart, a + // failure between them leaves an entry in the journal with the stay still + // listed as unhandled — and the next acceptance writes a second copy of it. + // + // The journey domain broadcasts the new entry from inside `createEntry`, so + // that one notification can still outrun a rollback. Moving it out means + // changing that domain's contract, which this integration has no business + // doing; the write itself is what had to stop being two. + const entry = this.db.transaction(() => { + const created = this.journey.createEntry( + body.journalId, + userId, + { + type: 'entry', + title: body.name?.trim() || row.name, + story: body.notes ?? undefined, + entry_date: body.date ?? row.local_date, + entry_time: body.time ?? timeOf(row.started_at) ?? undefined, + location_name: body.name?.trim() || row.name, + location_lat: body.lat ?? row.lat ?? undefined, + location_lng: body.lng ?? row.lng ?? undefined, + }, + sid, + ); + if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404); + this.markAccepted(row.id, 'journal', { journalEntryId: created.id }); + return created; + }); + return { + suggestion: this.getOne(userId, row.id)!, + createdPlaceId: null, + createdJournalEntryId: entry.id, + bucketListItemId: null, + }; + } + + /** A stay ticks off a wish. The wish must be the caller's own. */ + private acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): DawarichAcceptResult { + const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id; + if (!itemId) { + throw new AcceptError('bucket_required', 'A bucket-list entry is required', 400); + } + const item = this.db.get<{ id: number }>( + 'SELECT id FROM bucket_list WHERE id = ? AND user_id = ?', + itemId, + userId, + ); + if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404); + + this.db.transaction(() => { + this.db.run( + "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?", + row.started_at, + itemId, + userId, + ); + this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId }); + }); + + return { + suggestion: this.getOne(userId, row.id)!, + createdPlaceId: null, + createdJournalEntryId: null, + bucketListItemId: itemId, + }; + } + + /** + * The instance's answer to "may this person do this on this trip". + * + * The role comes out of the users table because the only thing every caller + * has in common is a user id: the REST controller holds the full user, the MCP + * tool holds `ctx.userId`, and a permission that depended on which door + * somebody came through would not be a permission. + */ + private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void { + const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId); + const allowed = this.permissions.checkPermission( + action, + actor?.role ?? 'user', + tripOwnerId, + userId, + tripOwnerId !== userId, + ); + if (!allowed) throw new AcceptError('forbidden', 'No permission', 403); + } + + /** + * Record what the acceptance produced, and freeze the hash it was accepted at. + * + * `accepted_hash` is what makes "the source changed since you accepted this" + * answerable at all: without it there is nothing to compare the current hash + * against, and the flag would either never fire or fire forever. + */ + private markAccepted( + id: number, + target: 'place' | 'journal' | 'bucket_list', + ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number }, + ): void { + this.db.run( + `UPDATE dawarich_visit_suggestions + SET state = 'accepted', target = ?, accepted_place_id = ?, + accepted_journal_entry_id = ?, accepted_bucket_list_item_id = ?, + accepted_hash = source_hash + WHERE id = ?`, + target, + ids.placeId ?? null, + ids.journalEntryId ?? null, + ids.bucketItemId ?? null, + id, + ); + } + + // ── Bucket-list scan ─────────────────────────────────────────────────────── + + /** + * Ask Dawarich, wish by wish, whether the user ever actually got there. + * + * This is the thorough half of the bucket-list match. The sync already links + * a wish to a stay when Dawarich's own detector happened to produce one + * nearby; this asks the recordings directly, which finds the places the + * detector never called a visit — the viewpoint you stopped at for half an + * hour on the way somewhere else. + * + * One upstream request per wish, so it is capped and explicitly triggered + * rather than run on a timer. The cap is reported, never silent: a user with + * eighty wishes must not be told their other thirty had no match. + */ + async scanBucketList(userId: number): Promise { + const creds = this.dawarich.getCredentials(userId); + if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400); + + const items = this.db.all( + `SELECT id, name, lat, lng, visited_at + FROM bucket_list WHERE user_id = ? + ORDER BY (visited_at IS NOT NULL), created_at DESC, id DESC`, + userId, + ); + const withCoords = items.filter((item) => item.lat !== null && item.lng !== null); + const skipped = items.length - withCoords.length; + const batch = withCoords.slice(0, DAWARICH_BUCKET_SCAN_LIMIT); + + const matches: DawarichBucketMatch[] = []; + for (const item of batch) { + matches.push({ + itemId: item.id, + name: item.name, + match: await this.bestStayNear(creds, item.lat!, item.lng!), + alreadyVisited: item.visited_at !== null, + }); + } + + return { + matches, + skippedWithoutCoordinates: skipped, + truncated: withCoords.length > batch.length, + fetchedAt: new Date().toISOString(), + }; + } + + /** + * The longest recorded stay near a coordinate that clears both thresholds. + * + * Longest rather than nearest: of two stays at the same landmark, the one + * that lasted two hours is the visit and the one that lasted four minutes is + * the bus stopping outside. + */ + private async bestStayNear( + creds: DawarichCreds, + lat: number, + lng: number, + ): Promise { + let stays; + try { + stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20); + } catch { + // One wish that could not be checked is not a failed scan; it reports as + // "no match found" and the user can run it again. + return null; + } + + let best: DawarichBucketMatch['match'] = null; + for (const stay of stays) { + const details = stay.visit_details ?? null; + const minutes = + toNumber(details?.duration_minutes) ?? + (details?.start_time && details?.end_time ? minutesBetween(details.start_time, details.end_time) : 0); + if (minutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) continue; + + const distance = toNumber(stay.distance_meters) ?? DAWARICH_BUCKET_MATCH_RADIUS_M; + if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue; + + // A stay with no readable timestamp at either end cannot be dated, and a + // wish ticked off on 1970-01-01 is worse than one that is not offered. + const at = details?.start_time ?? isoFromUnix(toNumber(stay.timestamp)); + if (!at) continue; + if (!best || minutes > best.minutes) { + best = { + at, + minutes, + distanceMeters: Math.round(distance), + points: toNumber(stay.points_count) ?? 0, + }; + } + } + return best; + } + + /** Tick off wishes the user confirmed after reading a scan. */ + confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): number { + const now = new Date().toISOString(); + let updated = 0; + this.db.transaction(() => { + for (const itemId of itemIds) { + // The date is the one the wish was actually reached on. A wish somebody + // got to in 2023 ticked off with today's date is a wrong entry in a list + // people keep for years, so "now" is only the answer when nothing else + // knows better: the caller's own value first, then the stay this wish + // was matched to, then the clock. + const stamp = visitedAt ?? this.matchedStayStart(userId, itemId) ?? now; + const result = this.db.run( + "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at IS NULL", + stamp, + itemId, + userId, + ); + updated += result.changes; + } + }); + return updated; + } + + /** When the stay that claimed this wish began, if one did. */ + private matchedStayStart(userId: number, itemId: number): string | null { + const row = this.db.get<{ started_at: string }>( + `SELECT started_at FROM dawarich_visit_suggestions + WHERE user_id = ? AND matched_bucket_list_item_id = ? + ORDER BY started_at DESC LIMIT 1`, + userId, + itemId, + ); + return row?.started_at ?? null; + } + + /** Undo a tick. Clears the source with it, so the entry reads as untouched again. */ + clearBucketVisit(userId: number, itemId: number): boolean { + const result = this.db.run( + 'UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?', + itemId, + userId, + ); + return result.changes > 0; + } + + // ── Atlas ────────────────────────────────────────────────────────────────── + + /** + * Countries and cities the recordings put the user in, offered to the Atlas. + * + * Offered, never applied. The Atlas is a thing people curate — it has + * tombstones precisely so a country somebody removed stays removed — and a + * background job that ticked countries off would fight that. `alreadyVisited` + * marks the ones TREK already counts, so the list reads as "these are new" + * rather than as a wall of things the user has known for years. + * + * Dawarich names countries rather than coding them, so the name is mapped + * through the Atlas's own table. Anything that does not resolve is listed by + * name instead of being dropped: a country TREK cannot code is still a + * country the user went to, and silently losing it would be the worse bug. + */ + async atlasSuggestions(userId: number, from: Date, to: Date): Promise { + const creds = this.dawarich.getCredentials(userId); + 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()), + ); + + const resolved: DawarichAtlasCountry[] = []; + const unresolved: string[] = []; + + for (const entry of countries) { + const name = (entry?.country ?? '').trim(); + if (!name) continue; + const code = NAME_TO_CODE[name.toLowerCase()]; + if (!code) { + unresolved.push(name); + continue; + } + resolved.push({ + countryCode: code, + sourceName: name, + cities: (entry.cities ?? []).map((city) => ({ + name: city?.city ?? '', + minutes: toNumber(city?.stayed_for) ?? 0, + lastSeenAt: isoFromUnix(toNumber(city?.timestamp)), + })).filter((city) => city.name !== ''), + alreadyVisited: visited.has(code), + }); + } + + return { countries: resolved, unresolved, fetchedAt: new Date().toISOString() }; + } + + /** + * Confirm a batch of countries into the Atlas. + * + * Goes through `AtlasService.markCountry`, which is what lifts the tombstone + * a previous removal left behind and keeps the two write paths from drifting. + * `INSERT OR IGNORE` underneath means a country already marked by hand keeps + * its own provenance — confirming it again does not relabel it as imported. + */ + acceptAtlasCountries(userId: number, codes: string[]): number { + let marked = 0; + this.db.transaction(() => { + for (const raw of codes) { + const code = raw.trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(code)) continue; + // Counted only when it was actually added: "3 countries added" must not + // include ones that were already on the map. + if (this.atlas.markCountry(userId, code, 'dawarich')) marked++; + } + }); + return marked; + } +} + +/** A refusal with a code the controller and the MCP tool each render their own way. */ +export class AcceptError extends Error { + readonly code: string; + readonly status: number; + constructor(code: string, message: string, status: number) { + super(message); + this.name = 'AcceptError'; + this.code = code; + this.status = status; + } +} + +/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */ +function timeOf(iso: string): string | null { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : null; +} + +function toWire(row: SuggestionJoinRow): DawarichSuggestion { + return { + id: row.id, + sourceVisitId: row.source_visit_id, + tripId: row.trip_id, + tripTitle: row.trip_title ?? null, + name: row.name, + lat: row.lat, + lng: row.lng, + startedAt: row.started_at, + endedAt: row.ended_at, + durationMinutes: row.duration_minutes, + localDate: row.local_date, + sourceStatus: row.source_status === 'confirmed' ? 'confirmed' : 'suggested', + confidence: row.confidence, + confidenceBand: row.confidence_band, + state: row.state === 'accepted' || row.state === 'dismissed' ? row.state : 'new', + target: + row.target === 'place' || row.target === 'journal' || row.target === 'bucket_list' ? row.target : null, + acceptedPlaceId: row.accepted_place_id, + acceptedJournalEntryId: row.accepted_journal_entry_id, + acceptedBucketListItemId: row.accepted_bucket_list_item_id, + // Only meaningful once something was accepted: before that there is no + // "what you agreed to" to have diverged from. + sourceChanged: row.accepted_hash !== null && row.accepted_hash !== row.source_hash, + sourceMissing: row.source_missing_at !== null, + matchedBucketListItemId: row.matched_bucket_list_item_id, + matchedBucketListName: row.matched_bucket_name ?? null, + countryCode: row.country_code, + firstSeenAt: row.first_seen_at, + lastSeenAt: row.last_seen_at, + }; +} + +/** A Unix timestamp as ISO-8601, or null when there was none to read. */ +function isoFromUnix(seconds: number | null | undefined): string | null { + if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return null; + return new Date(seconds * 1000).toISOString(); +} + +interface SuggestionRow { + id: number; + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + state: string; + target: string | null; + accepted_place_id: number | null; + accepted_journal_entry_id: number | null; + accepted_bucket_list_item_id: number | null; + matched_bucket_list_item_id: number | null; + source_hash: string; + accepted_hash: string | null; + source_missing_at: string | null; + first_seen_at: string; + last_seen_at: string; +} + +interface SuggestionJoinRow extends SuggestionRow { + trip_title: string | null; + matched_bucket_name: string | null; +} + +interface BucketRow { + id: number; + name: string; + lat: number | null; + lng: number | null; + visited_at: string | null; +} diff --git a/server/src/nest/integrations/dawarich-sync.job.ts b/server/src/nest/integrations/dawarich-sync.job.ts new file mode 100644 index 000000000..779ff7657 --- /dev/null +++ b/server/src/nest/integrations/dawarich-sync.job.ts @@ -0,0 +1,47 @@ +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; +import { logInfo, logError } from '../audit/audit-log.logger'; +import { DatabaseService } from '../database/database.service'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { DawarichSyncService } from './dawarich-sync.service'; + +/** + * Polls every connected Dawarich instance for new visits (#2279). + * + * Slower than the AirTrail job by an order of magnitude, and on purpose: a + * flight lands once, while a visit detector produces stays continuously, and + * nobody needs a stay from four minutes ago on their planning screen. Fifteen + * minutes keeps a whole afternoon's suggestions arriving in time to be useful + * without turning a self-hosted instance into a busy server. + * + * The addon gate is re-read per tick inside the service, so switching the addon + * off takes effect without a restart; the interval is read once at bootstrap, + * matching how AirTrail behaves. + */ +@Injectable() +export class DawarichSyncJob implements OnApplicationBootstrap { + constructor( + private readonly db: DatabaseService, + private readonly sync: DawarichSyncService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + const value = this.db.get<{ value: string }>( + 'SELECT value FROM app_settings WHERE key = ?', + 'dawarich_poll_interval_minutes', + )?.value; + const raw = Number.parseInt(value || '15', 10); + const minutes = Number.isFinite(raw) && raw >= 5 && raw <= 59 ? raw : 15; + logInfo(`Dawarich sync: scheduled every ${minutes}m`); + this.registrar.register('dawarich-sync', `*/${minutes} * * * *`, () => this.tick()); + } + + async tick(): Promise { + try { + await this.sync.runSync(); + } catch (err: unknown) { + logError(`Dawarich sync tick failed: ${err instanceof Error ? err.message : err}`); + } + } +} diff --git a/server/src/nest/integrations/dawarich-sync.service.ts b/server/src/nest/integrations/dawarich-sync.service.ts new file mode 100644 index 000000000..08d8f5dce --- /dev/null +++ b/server/src/nest/integrations/dawarich-sync.service.ts @@ -0,0 +1,525 @@ +import { Injectable } from '@nestjs/common'; +import { + DAWARICH_SYNC_LOOKAHEAD_DAYS, + DAWARICH_SYNC_LOOKBACK_DAYS, + DAWARICH_BUCKET_MATCH_RADIUS_M, + DAWARICH_BUCKET_MATCH_MIN_MINUTES, + type DawarichSyncState, +} from '@trek/shared'; +import { ADDON_IDS } from '../../addons'; +import { DatabaseService } from '../database/database.service'; +import { AddonsService } from '../addons/addons.service'; +import { logError, logInfo } from '../audit/audit-log.logger'; +import { getCountryFromCoords } from '../atlas/atlas-geo'; +import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client'; +import { DawarichService } from './dawarich.service'; +import { distanceMeters, localDateOf, normalizeVisit, syncWindow, visitHash } from './dawarich.helpers'; + +/** What one user's sync produced — plus whether it ran at all. */ +export interface DawarichSyncOutcome { + state: DawarichSyncState; + created: number; + updated: number; + missing: number; + /** True when a sync for this user was already in flight, so nothing was asked. */ + alreadyRunning?: boolean; +} + +/** + * Pulls Dawarich visits into TREK as reviewable suggestions. + * + * Three rules run through everything here, and they are the whole reason this + * is a sync rather than an import: + * + * 1. **A sync never changes shared trip content.** It writes rows into + * `dawarich_visit_suggestions`, which belong to the person whose credentials + * fetched them. A place or a journal entry appears only when that person + * accepts one, in `DawarichSuggestionsService`. + * 2. **Repeating a sync is free.** Identity is (user, Dawarich visit id) with a + * UNIQUE behind it, so the tenth run over the same window produces the same + * rows as the first. + * 3. **The source is allowed to change its mind; TREK is not allowed to act on + * that alone.** A visit whose content hash moved is flagged `source_changed` + * and one that vanished from a full re-read is flagged `source_missing` — + * both visible, neither applied. A suggestion the user already accepted and + * then edited is their text now; rewriting or deleting it because a detector + * ran again would be the integration overwriting someone's journal. + */ +@Injectable() +export class DawarichSyncService { + /** + * Module-level rather than per-call: a cron tick that overlaps the previous + * one would ask the same instance for the same window twice and race on the + * same rows. + */ + private running = false; + + /** + * Who is being synced right now, cron or button. + * + * The module-level flag only guards the cron against itself. "Check now" calls + * `syncUser` directly, so without this a held-down button — or a button pressed + * while the cron is working — would ask the same instance for the same windows + * several times over, each walking up to 20 pages, and let two passes race on + * the same rows. A second request is answered with the truth: one is running. + */ + private readonly inFlight = new Set(); + + constructor( + private readonly db: DatabaseService, + private readonly addons: AddonsService, + private readonly client: DawarichClient, + private readonly dawarich: DawarichService, + ) {} + + /** The addon gate, evaluated per tick so an admin toggle lands without a restart. */ + syncGloballyEnabled(): boolean { + return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH); + } + + /** Every connected user, one after another. The cron's entry point. */ + async runSync(): Promise { + if (!this.syncGloballyEnabled()) return; + if (this.running) return; + this.running = true; + try { + const userIds = this.dawarich.listSyncableUserIds(); + for (const userId of userIds) { + try { + await this.syncUser(userId); + } catch (err) { + // One unreachable instance must not stop the others. + logError(`Dawarich sync failed for user ${userId}: ${err instanceof Error ? err.message : err}`); + } + } + } finally { + this.running = false; + } + } + + /** + * One user's trips. + * + * The result is deliberately three-valued. `partial` is the interesting one: + * some windows came back and some did not, which is what a flaky instance or + * a very long trip actually looks like, and calling that either "ok" or + * "failed" would mislead the person reading the connection card. + */ + async syncUser(userId: number): Promise { + if (this.inFlight.has(userId)) { + // Not a failure and not a fresh result: the run that is already going will + // record its own. Reporting the state the connection currently holds keeps + // the card honest, and `alreadyRunning` lets the caller say so. + const current = this.db.get<{ last_sync_state: string }>( + 'SELECT last_sync_state FROM dawarich_connections WHERE user_id = ?', + userId, + ); + return { + state: (current?.last_sync_state as DawarichSyncState) ?? 'never', + created: 0, + updated: 0, + missing: 0, + alreadyRunning: true, + }; + } + this.inFlight.add(userId); + try { + return await this.syncUserOnce(userId); + } finally { + this.inFlight.delete(userId); + } + } + + /** The body of a sync, with the guard above already held. */ + private async syncUserOnce(userId: number): Promise { + if (!this.syncGloballyEnabled()) { + this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled'); + return { state: 'failed', created: 0, updated: 0, missing: 0 }; + } + + const creds = this.dawarich.getCredentials(userId); + if (!creds) { + this.dawarich.recordSyncResult(userId, 'failed', 'not_connected'); + return { state: 'failed', created: 0, updated: 0, missing: 0 }; + } + + const trips = this.listTripsToSync(userId); + if (trips.length === 0) { + // Nothing to ask about is a successful sync, not a failure — otherwise a + // user with no dated trips sees a permanent red badge for doing nothing wrong. + this.dawarich.recordSyncResult(userId, 'ok', null); + return { state: 'ok', created: 0, updated: 0, missing: 0 }; + } + + let created = 0; + let updated = 0; + let missing = 0; + let failures = 0; + let lastError: string | null = null; + const now = new Date(); + + for (const trip of trips) { + const window = syncWindow( + trip.start_date, + trip.end_date, + now, + DAWARICH_SYNC_LOOKBACK_DAYS, + DAWARICH_SYNC_LOOKAHEAD_DAYS, + ); + if (!window) continue; + + try { + const result = await this.syncTripWindow(userId, trip.id, creds, window.from, window.to); + created += result.created; + updated += result.updated; + missing += result.missing; + } catch (err) { + failures++; + lastError = err instanceof DawarichError ? err.code : 'unreachable'; + } + } + + const state: DawarichSyncState = + failures === 0 ? 'ok' : failures === trips.length ? 'failed' : 'partial'; + this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError); + + // The probe is cheap next to the windows just fetched, and re-running it is + // how a Dawarich upgrade that finally ships `updated_at` starts being used + // without anyone reconnecting. + if (state !== 'failed') { + try { + this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds)); + } catch { + // Capabilities are an optimisation; a failed probe is not a failed sync. + } + } + + if (created || updated || missing) { + logInfo(`Dawarich sync for user ${userId}: ${created} new, ${updated} changed, ${missing} gone`); + } + return { state, created, updated, missing }; + } + + /** + * Trips worth asking about: the ones the user owns or is on, not archived, + * with a start date, and either still running or recent enough that a + * recording could still arrive. + * + * A trip five years past is not re-polled every five minutes. The cut-off is + * generous rather than tight, because someone who connects Dawarich for the + * first time wants their last holiday filled in, not just today's. + */ + private listTripsToSync(userId: number): TripRow[] { + return this.db.all( + `SELECT DISTINCT t.id, t.start_date, t.end_date + FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE (t.user_id = ? OR m.user_id IS NOT NULL) + AND COALESCE(t.is_archived, 0) = 0 + AND t.start_date IS NOT NULL + AND (t.end_date IS NULL OR t.end_date >= date('now', '-400 days')) + AND t.start_date <= date('now', '+1 day') + ORDER BY t.start_date DESC`, + userId, + userId, + ); + } + + /** + * One trip's window: fetch, reconcile, flag what disappeared. + * + * The reconciliation is a full re-read of the window rather than a delta, + * and that is not laziness. Dawarich sends no `updated_at` to build a delta + * from, and deleting a visit removes it from the list rather than tombstoning + * 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. + */ + async syncTripWindow( + userId: number, + tripId: number, + creds: DawarichCreds, + from: Date, + to: Date, + ): Promise<{ created: number; updated: number; missing: number }> { + const { visits } = await this.client.listVisits(creds, from, to); + + const seenIds = new Set(); + let created = 0; + let updated = 0; + + for (const raw of visits) { + const visit = normalizeVisit(raw); + if (!visit) continue; + seenIds.add(visit.sourceVisitId); + + const hash = visitHash(raw); + const existing = this.db.get( + 'SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?', + userId, + visit.sourceVisitId, + ); + + // Dawarich has no country code on a visit's place, so it is resolved here + // from the coordinates against the same borders the Atlas draws. A code + // the source does start sending one day wins, because it is the source's + // own answer. + const countryCode = + visit.countryCodeFromSource ?? + (visit.lat !== null && visit.lng !== null ? getCountryFromCoords(visit.lat, visit.lng) : null); + + if (!existing) { + this.db.run( + `INSERT INTO dawarich_visit_suggestions + (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, + duration_minutes, local_date, source_status, confidence, confidence_band, + country_code, state, source_hash, first_seen_at, last_seen_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)`, + userId, + visit.sourceVisitId, + tripId, + visit.name, + visit.lat, + visit.lng, + visit.startedAt, + visit.endedAt, + visit.durationMinutes, + visit.localDate, + visit.status, + visit.confidence, + visit.confidenceBand, + countryCode, + hash, + new Date().toISOString(), + new Date().toISOString(), + ); + created++; + this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); + continue; + } + + const changed = existing.source_hash !== hash; + + 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. + 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 = ? + WHERE id = ?`, + visit.name, + visit.lat, + visit.lng, + visit.startedAt, + visit.endedAt, + visit.durationMinutes, + visit.localDate, + visit.status, + visit.confidence, + visit.confidenceBand, + countryCode, + hash, + tripId, + new Date().toISOString(), + existing.id, + ); + if (changed) updated++; + this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes); + continue; + } + + // Accepted or dismissed: the hash is refreshed so the flag is raised once + // rather than on every tick, but nothing the user can see is rewritten. + // `accepted_hash` is what they said yes to; the difference between that + // and the current hash is what the UI reports. + this.db.run( + `UPDATE dawarich_visit_suggestions + SET source_hash = ?, source_missing_at = NULL, last_seen_at = ? + WHERE id = ?`, + hash, + new Date().toISOString(), + existing.id, + ); + if (changed) updated++; + } + + const missing = this.flagMissing(userId, tripId, from, to, seenIds); + return { created, updated, missing }; + } + + /** + * Mark everything TREK holds for this window that the source no longer lists. + * + * Untouched suggestions are removed outright — nobody has seen them, and a + * list of stays that no longer exist is worse than an empty list. Anything the + * user acted on keeps its row and gets a timestamp instead, so the panel can + * say "this is gone in Dawarich" next to an entry they wrote. Deleting that + * would delete their work. + */ + private flagMissing( + userId: number, + tripId: number, + from: Date, + to: Date, + seenIds: Set, + ): number { + // Bounded on `local_date`, not on `started_at`. Dawarich renders a visit's + // start as local wall-clock plus an offset (`2026-09-01T14:23:00+02:00`) + // while the window is UTC (`…Z`), and comparing those two shapes as strings + // is only accurate to the date — on a statement that DELETES rows, "only + // accurate to the date" is not good enough. `local_date` is a plain + // YYYY-MM-DD on both sides of the comparison, and a day of slack at each + // end is deliberate: a row just outside the window is left alone rather + // than deleted for not having been seen. + const candidates = this.db.all<{ id: number; source_visit_id: string; state: string }>( + `SELECT id, source_visit_id, state + FROM dawarich_visit_suggestions + WHERE user_id = ? AND trip_id = ? + AND local_date >= ? AND local_date <= ?`, + userId, + tripId, + localDateOf(from.toISOString()), + localDateOf(to.toISOString()), + ); + + const gone = candidates.filter((row) => !seenIds.has(row.source_visit_id)); + if (gone.length === 0) return 0; + + const stamp = new Date().toISOString(); + this.db.transaction(() => { + for (const row of gone) { + if (row.state === 'new') { + this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id); + } else { + this.db.run( + 'UPDATE dawarich_visit_suggestions SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?', + stamp, + row.id, + ); + } + } + }); + return gone.length; + } + + /** + * Link a stay to a bucket-list wish it sits on top of. + * + * Both tests have to pass: close enough, and long enough. Proximity alone is + * what makes a detector untrustworthy — driving past a cathedral puts you + * within 250 m of it — and the dwell time is what separates being somewhere + * from going past it. The link is only a hint; ticking the wish off still + * needs the user. + */ + private matchBucketList( + userId: number, + sourceVisitId: string, + lat: number | null, + lng: number | null, + durationMinutes: number, + ): void { + if (lat === null || lng === null) return; + if (durationMinutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) return; + + // A rough box first so SQLite does not measure every wish in the world. One + // degree of latitude is ~111 km, and longitude shrinks with latitude, so the + // box is deliberately generous and the real test is the distance below. + const degrees = (DAWARICH_BUCKET_MATCH_RADIUS_M / 111_000) * 2 + 0.01; + const nearby = this.db.all<{ id: number; lat: number; lng: number }>( + `SELECT id, lat, lng FROM bucket_list + WHERE user_id = ? AND lat IS NOT NULL AND lng IS NOT NULL + AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`, + userId, + lat - degrees, + lat + degrees, + lng - degrees, + lng + degrees, + ); + + let best: { id: number; lat: number; lng: number; distance: number } | null = null; + for (const item of nearby) { + const distance = distanceMeters(lat, lng, item.lat, item.lng); + if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue; + if (!best || distance < best.distance) best = { id: item.id, lat: item.lat, lng: item.lng, distance }; + } + if (!best) return; + + this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes); + } + + /** + * Give a wish to the one stay that best answers it. + * + * 250 m is a city block, and a block in a city centre holds a dozen stays: a + * coffee across the square from the museum satisfies the radius exactly as + * the museum does. Attaching the wish to each of them turns one achievement + * into five claims and invites ticking it off from the wrong one, so the + * closest stay keeps it — and the longest wins a tie, because standing + * somewhere for two hours is a better answer to "were you there" than passing + * within the same few metres. + * + * Every previous claim on the same wish is cleared, so re-running a sync after + * the recordings change cannot leave two stays holding it. + */ + private claimWish( + userId: number, + sourceVisitId: string, + wish: { id: number; lat: number; lng: number; distance: number }, + lat: number, + lng: number, + durationMinutes: number, + ): void { + const holders = this.db.all<{ + id: number; + source_visit_id: string; + lat: number | null; + lng: number | null; + duration_minutes: number; + }>( + `SELECT id, source_visit_id, lat, lng, duration_minutes + FROM dawarich_visit_suggestions + WHERE user_id = ? AND matched_bucket_list_item_id = ? AND source_visit_id <> ?`, + userId, + wish.id, + sourceVisitId, + ); + + for (const holder of holders) { + if (holder.lat === null || holder.lng === null) continue; + const distance = distanceMeters(holder.lat, holder.lng, wish.lat, wish.lng); + const holderWins = + distance < wish.distance || + (distance === wish.distance && holder.duration_minutes > durationMinutes); + if (holderWins) return; + } + + this.db.transaction(() => { + this.db.run( + 'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?', + userId, + wish.id, + ); + this.db.run( + 'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?', + wish.id, + userId, + sourceVisitId, + ); + }); + } +} + +interface TripRow { + id: number; + start_date: string | null; + end_date: string | null; +} + +interface SuggestionRow { + id: number; + state: string; + source_hash: string; +} diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts new file mode 100644 index 000000000..386ee0f63 --- /dev/null +++ b/server/src/nest/integrations/dawarich-tracks.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common'; +import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack } from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; +import { DawarichClient, type DawarichCreds } from './dawarich.client'; +import { DawarichService } from './dawarich.service'; +import { + bucketPointsByDay, + bucketTracksByDay, + countPoints, + offsetMinutesOf, +} from './dawarich.helpers'; + +/** + * The recorded route for a window, fetched on demand and never stored. + * + * That is the point of the whole feature and the one rule it must not break: + * Dawarich owns the location archive. TREK draws the part of it somebody is + * looking at right now and forgets it again. There is no table here, no + * `route_geometry` written to a place, no background import. + * + * What there is instead is a short in-memory cache, because "update the line + * while the map is open" and "ask an instance for a month of GPS every few + * seconds" are the same request otherwise. It lives for a minute, it is keyed + * per user and window, and losing it on restart costs one extra fetch. + */ +@Injectable() +export class DawarichTracksService { + private readonly cache = new Map(); + + /** + * Long enough that panning and toggling the layer do not re-fetch, short + * enough that a live recording visibly catches up. + */ + private static readonly TTL_MS = 60_000; + + /** + * A ceiling, not a sizing estimate: the cache holds whole days of geometry + * and a user flipping through a year of trips would otherwise keep every one + * of them alive. Oldest entry out first. + */ + private static readonly MAX_ENTRIES = 64; + + constructor( + private readonly db: DatabaseService, + private readonly dawarich: DawarichService, + private readonly client: DawarichClient, + ) {} + + /** + * The recorded route for a trip, grouped by local day. + * + * `from`/`to` narrow it further so the planner can ask for one day without + * pulling the whole trip; without them the trip's own dates are the window. + * Returns null when the caller cannot read the trip — the controller turns + * that into the same 404 a missing trip gets. + */ + async forTrip( + userId: number, + tripId: number, + from?: string, + to?: string, + offsetMinutes = 0, + ): Promise { + if (!this.db.canAccessTrip(tripId, userId)) return null; + + const trip = this.db.get<{ start_date: string | null; end_date: string | null }>( + 'SELECT start_date, end_date FROM trips WHERE id = ?', + tripId, + ); + if (!trip) return null; + + const start = from ?? trip.start_date; + const end = to ?? trip.end_date ?? trip.start_date; + if (!start || !end) { + // A trip without dates has no window to ask about. An empty result says + // so honestly; an error would suggest the connection is broken. + return emptyTrack(); + } + + return this.forWindow(userId, `${start}T00:00:00Z`, `${end}T23:59:59Z`, offsetMinutes); + } + + /** + * The recorded route for an explicit instant range. + * + * Used by the journal, which has dated entries rather than a trip window. + */ + async forWindow( + userId: number, + fromIso: string, + toIso: string, + offsetMinutes = offsetMinutesOf(fromIso), + ): Promise { + const creds = this.dawarich.getCredentials(userId); + if (!creds) return emptyTrack(); + + const from = new Date(fromIso); + const to = new Date(toIso); + if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to <= from) { + return emptyTrack(); + } + + // The offset is part of the key: the same window grouped by two different + // days is two different answers, and one traveller can ask for both across + // a timezone change. + const key = `${userId}:${from.toISOString()}:${to.toISOString()}:${offsetMinutes}`; + const hit = this.cache.get(key); + if (hit && Date.now() - hit.at < DawarichTracksService.TTL_MS) return hit.value; + + const value = await this.fetch(creds, userId, from, to, offsetMinutes); + this.remember(key, value); + return value; + } + + /** + * Tracks first, points as the fallback. + * + * `/tracks` returns lines Dawarich has already segmented and labelled with a + * travel mode, which is both a smaller payload and a better line than raw + * points. But tracks are produced by a background job, so a fresh import — + * or an instance old enough not to have the endpoint — has points long before + * it has tracks. Falling back keeps the overlay working on both, and the + * result says which one answered so the UI can be honest about it. + */ + private async fetch( + creds: DawarichCreds, + userId: number, + from: Date, + to: Date, + offsetMinutes: number, + ): Promise { + const capabilities = this.dawarich.getCapabilities(userId); + + if (capabilities?.tracks !== false) { + try { + const { features, truncated } = await this.client.listTracks(creds, from, to); + if (features.length > 0) { + const days = bucketTracksByDay(features, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY }); + return { + days, + source: 'tracks', + fetchedAt: new Date().toISOString(), + pointCount: countPoints(days), + truncated, + }; + } + } catch { + // Fall through to points: an instance that cannot serve tracks can + // usually still serve the points they would have been built from. + } + } + + const { points, truncated } = await this.client.listPoints(creds, from, to); + // Points carry a Unix timestamp and no zone, so the local day has to come + // from somewhere. It comes from the caller: the browser knows the offset the + // reader is living in, and "which day was that" is their question, not the + // server's. Zero — plain UTC — is the answer for a caller that says nothing, + // which is what a Date can ever tell us (`toISOString` always ends in `Z`, + // so deriving it from the window here only ever produced UTC). + const days = bucketPointsByDay(points, offsetMinutes, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY }); + return { + days, + source: 'points', + fetchedAt: new Date().toISOString(), + pointCount: countPoints(days), + truncated, + }; + } + + private remember(key: string, value: DawarichTrack): void { + if (this.cache.size >= DawarichTracksService.MAX_ENTRIES) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } + this.cache.set(key, { at: Date.now(), value }); + } + + /** Drop everything cached for one user — used when a connection changes. */ + forget(userId: number): void { + for (const key of [...this.cache.keys()]) { + if (key.startsWith(`${userId}:`)) this.cache.delete(key); + } + } +} + +function emptyTrack(): DawarichTrack { + return { + days: [], + source: 'tracks', + fetchedAt: new Date().toISOString(), + pointCount: 0, + truncated: false, + }; +} diff --git a/server/src/nest/integrations/dawarich.client.ts b/server/src/nest/integrations/dawarich.client.ts new file mode 100644 index 000000000..a5b180b33 --- /dev/null +++ b/server/src/nest/integrations/dawarich.client.ts @@ -0,0 +1,426 @@ +import { Injectable } from '@nestjs/common'; +import type { DawarichErrorCode } from '@trek/shared'; +import { safeFetch } from '../../utils/ssrfGuard'; +import { readCappedJson } from '../../utils/cappedFetch'; + +/** + * Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich). + * This is the ONLY place that talks to a user's Dawarich instance. + * + * Verified against the Dawarich source at 1.14.4 (2026-09-06), not against its + * prose documentation — the two disagree in the places that matter here: + * + * - **Auth**: `Authorization: Bearer ` (or `?api_key=`, which this client + * never uses — a key in a query string ends up in every access log between + * here and there). `User.find_by(api_key:)` means one key per user and no + * scopes: the key reads that user's whole archive. Granular Dawarich tokens + * were discussed and have not shipped, so TREK must not depend on them. + * - **Version**: every response carries `X-Dawarich-Version`. That is the + * instance's own word on what it is, which beats guessing from behaviour. + * - `GET /api/v1/health` skips authentication, so it proves reachability and + * nothing else. `GET /api/v1/users/me` is the key test. + * - `GET /api/v1/visits?start_at&end_at` filters on `started_at` ONLY. A stay + * that began before the window is not returned even if it ran well into it, + * so callers widen the window rather than trusting its edges. Without a + * `page` parameter it returns the whole range in one response; with one, the + * page size caps at 500 and the count arrives in `X-Total-Pages`. + * - A visit carries **no `updated_at`** and its `place` carries **no country + * code**. Change detection is therefore hash-based and the country is + * resolved from the coordinates; see dawarich-sync.service.ts. + * - Deleting a visit is a soft delete that removes it from the index. Absence + * from a full re-read is the only deletion signal there is. + * - `GET /api/v1/tracks` answers GeoJSON with one LineString per track and + * `start_at`/`end_at` in the properties — a far better overlay source than + * raw points. `GET /api/v1/points?slim=true` is the fallback, and its + * `latitude`/`longitude` arrive as **strings**. + */ + +const TIMEOUT_MS = 15000; + +/** A page of visits is a few hundred small objects; a megabyte is misbehaviour. */ +const MAX_JSON_BYTES = 8 * 1024 * 1024; + +/** Dawarich's own ceiling for `per_page` on visits. Asking for more is silently capped. */ +export const VISITS_PAGE_SIZE = 500; +/** Tracks default to 500 per page and carry whole geometries, so we ask for fewer. */ +export const TRACKS_PAGE_SIZE = 200; +/** Points cap at 10000 upstream. A day of tracking is a few thousand. */ +export const POINTS_PAGE_SIZE = 5000; + +/** + * How many pages one call will walk before giving up and saying so. + * + * A cap rather than a loop: a misconfigured instance answering the same page + * forever would otherwise hold a request open until the timeout, and a caller + * that is told the result is incomplete can do something about it. Every + * paginated method reports `truncated` rather than pretending it saw everything. + */ +const MAX_PAGES = 20; + +export interface DawarichCreds { + /** Instance origin without a trailing /api. */ + baseUrl: string; + apiKey: string; + allowInsecureTls: boolean; +} + +/** + * A failed Dawarich call, carrying the reason as a code. + * + * The code is what reaches the user (through i18n on the client) because the + * upstream message is English, occasionally quotes the URL back, and on a + * misconfigured reverse proxy is an HTML login page. `detail` keeps the + * original for the places where a human is debugging their own setup. + */ +export class DawarichError extends Error { + readonly code: DawarichErrorCode; + readonly status?: number; + readonly detail?: string; + + constructor(code: DawarichErrorCode, message: string, status?: number, detail?: string) { + super(message); + this.name = 'DawarichError'; + this.code = code; + this.status = status; + this.detail = detail; + } +} + +/** A visit exactly as `Api::VisitSerializer` emits it. Nothing is assumed optional-but-present. */ +export interface DawarichVisitRaw { + id: number | string; + area_id: number | null; + user_id?: number; + started_at: string; + ended_at: string; + /** Seconds in current builds; TREK normalises rather than trusting the unit. */ + duration: number | string | null; + name: string | null; + status: string | null; + confidence: number | string | null; + confidence_band: string | null; + place: { + latitude: number | string | null; + longitude: number | string | null; + id: number | null; + /** Requested upstream, absent at 1.14.4. Read defensively so it lights up on its own. */ + country_code?: string | null; + } | null; + /** Requested upstream, absent at 1.14.4. */ + updated_at?: string | null; +} + +/** One GeoJSON feature from `GET /api/v1/tracks`. */ +export interface DawarichTrackFeature { + type?: string; + geometry: { type?: string; coordinates?: unknown } | null; + properties: { + id?: number | string; + start_at?: string; + end_at?: string; + distance?: number | null; + duration?: number | null; + dominant_mode?: string | null; + } | null; +} + +/** One entry of `GET /api/v1/points?slim=true`. lat/lng are strings upstream. */ +export interface DawarichSlimPoint { + id?: number; + latitude: string | number; + longitude: string | number; + /** Unix seconds. */ + timestamp: number; + country_name?: string | null; +} + +/** One country of `GET /api/v1/countries/visited_cities`. */ +export interface DawarichVisitedCountry { + country: string; + cities: Array<{ + city: string; + points?: number; + /** Unix seconds of the last point in that city. */ + timestamp?: number; + /** Minutes. */ + stayed_for?: number; + }>; +} + +/** One stay near a coordinate from `GET /api/v1/locations`. */ +export interface DawarichLocationVisit { + timestamp: number; + date?: string; + distance_meters?: number; + points_count?: number; + visit_details?: { + start_time?: string; + end_time?: string; + duration_minutes?: number; + city?: string | null; + country?: string | null; + } | null; +} + +export interface DawarichProbe { + /** From `X-Dawarich-Version`, null on an instance too old to send it. */ + version: string | null; +} + +/** + * `https://host/extra` → `https://host/extra/api/v1`, tolerating what people + * actually paste: a trailing slash, a trailing `/api`, or the full `/api/v1`. + * Without this, half the support load is `/api/api/v1` 404s. + */ +function apiBase(baseUrl: string): string { + const origin = baseUrl + .trim() + .replace(/(?( + creds: DawarichCreds, + path: string, + query: Record, + ): Promise<{ data: T; headers: Headers }> { + const url = new URL(apiBase(creds.baseUrl) + path); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, value); + } + + let response: Response; + try { + response = await safeFetch( + url.toString(), + { + method: 'GET', + headers: { + Authorization: `Bearer ${creds.apiKey}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(TIMEOUT_MS) as AbortSignal, + }, + { rejectUnauthorized: !creds.allowInsecureTls }, + ); + } catch (err: unknown) { + throw new DawarichError( + 'unreachable', + 'Could not reach Dawarich', + undefined, + err instanceof Error ? err.message : undefined, + ); + } + + if (!response.ok) { + throw new DawarichError( + classify(response.status), + `Dawarich answered HTTP ${response.status}`, + response.status, + ); + } + + // Read through the cap rather than calling .json(): Dawarich answers chunked + // over large ranges, so there is no content-length to check first, and an + // auth proxy in front of it answers with an HTML login page that would + // otherwise surface as "Unexpected token '<'". + const data = await readCappedJson(response, MAX_JSON_BYTES); + if (data === undefined) { + throw new DawarichError( + 'invalid_response', + 'Dawarich returned something that is not JSON. Check the URL points at Dawarich itself and that it is reachable without a separate login.', + ); + } + return { data, headers: response.headers }; + } + + /** + * Authenticate and read the version banner. + * + * `/users/me` rather than `/health`, because health skips authentication + * upstream: a wrong key would pass it, and "connected" would mean nothing. + */ + async probe(creds: DawarichCreds): Promise { + const { headers } = await this.get(creds, '/users/me', {}); + return { version: headers.get('x-dawarich-version') }; + } + + /** + * Visits whose stay STARTED inside the window. + * + * Paginated explicitly even though the endpoint returns everything without a + * `page` parameter: a multi-year archive in one response is exactly the shape + * the size cap exists to refuse, and the caller would get an error instead of + * a long answer. + */ + async listVisits( + creds: DawarichCreds, + from: Date, + to: Date, + ): Promise<{ visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }> { + const visits: DawarichVisitRaw[] = []; + let version: string | null = null; + let page = 1; + let truncated = false; + + for (; page <= MAX_PAGES; page++) { + const { data, headers } = await this.get(creds, '/visits', { + start_at: isoUtc(from), + end_at: isoUtc(to), + page: String(page), + per_page: String(VISITS_PAGE_SIZE), + }); + version ??= headers.get('x-dawarich-version'); + if (!Array.isArray(data)) { + throw new DawarichError('invalid_response', 'Dawarich returned a visits payload that is not a list'); + } + visits.push(...data); + const totalPages = Number(headers.get('x-total-pages') ?? '1'); + if (!Number.isFinite(totalPages) || page >= totalPages) break; + if (page === MAX_PAGES) truncated = true; + } + + return { visits, truncated, version }; + } + + /** + * Pre-segmented recorded routes overlapping the window, as GeoJSON features. + * + * Unlike visits, the upstream filter is a real overlap test + * (`end_at >= from AND start_at <= to`), so a drive that crosses the window's + * edge is included rather than dropped. + */ + async listTracks( + creds: DawarichCreds, + from: Date, + to: Date, + ): Promise<{ features: DawarichTrackFeature[]; truncated: boolean }> { + const features: DawarichTrackFeature[] = []; + let truncated = false; + + for (let page = 1; page <= MAX_PAGES; page++) { + const { data, headers } = await this.get<{ features?: DawarichTrackFeature[] }>(creds, '/tracks', { + start_at: isoUtc(from), + end_at: isoUtc(to), + page: String(page), + per_page: String(TRACKS_PAGE_SIZE), + }); + const batch = Array.isArray(data?.features) ? data.features : []; + features.push(...batch); + const totalPages = Number(headers.get('x-total-pages') ?? '1'); + if (!Number.isFinite(totalPages) || page >= totalPages) break; + if (page === MAX_PAGES) truncated = true; + } + + return { features, truncated }; + } + + /** + * Raw recorded points, oldest first — the fallback when an instance has not + * generated tracks (they are produced by a background job, so a fresh import + * has points long before it has tracks). + */ + async listPoints( + creds: DawarichCreds, + from: Date, + to: Date, + ): Promise<{ points: DawarichSlimPoint[]; truncated: boolean }> { + const points: DawarichSlimPoint[] = []; + let truncated = false; + + for (let page = 1; page <= MAX_PAGES; page++) { + const { data, headers } = await this.get(creds, '/points', { + start_at: unixSeconds(from), + end_at: unixSeconds(to), + order: 'asc', + slim: 'true', + page: String(page), + per_page: String(POINTS_PAGE_SIZE), + }); + if (!Array.isArray(data)) { + throw new DawarichError('invalid_response', 'Dawarich returned a points payload that is not a list'); + } + points.push(...data); + const totalPages = Number(headers.get('x-total-pages') ?? '1'); + if (!Number.isFinite(totalPages) || page >= totalPages) break; + if (page === MAX_PAGES) truncated = true; + } + + return { points, truncated }; + } + + /** Countries and cities the recordings put the user in, for the Atlas. */ + async listVisitedCities( + creds: DawarichCreds, + from: Date, + to: Date, + ): Promise { + const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', { + start_at: unixSeconds(from), + end_at: unixSeconds(to), + }); + return Array.isArray(data?.data) ? data.data : []; + } + + /** + * "Was I ever here, and for how long?" for one coordinate. + * + * `radius_override` is passed explicitly: without it Dawarich picks a radius + * from the place TYPE, and a coordinate search has no type, so it falls back + * to 500 m — wide enough to call a drive past a landmark a visit. + */ + async findVisitsNear( + creds: DawarichCreds, + lat: number, + lng: number, + radiusMeters: number, + limit: number, + ): Promise { + const { data } = await this.get<{ + locations?: Array<{ visits?: DawarichLocationVisit[] }>; + }>(creds, '/locations', { + lat: String(lat), + lon: String(lng), + radius_override: String(Math.round(radiusMeters)), + limit: String(limit), + }); + const first = Array.isArray(data?.locations) ? data.locations[0] : undefined; + return Array.isArray(first?.visits) ? first.visits : []; + } +} diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts new file mode 100644 index 000000000..e591cdc11 --- /dev/null +++ b/server/src/nest/integrations/dawarich.controller.ts @@ -0,0 +1,351 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpException, + Param, + Post, + Put, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import type { Request } from 'express'; +import type { User } from '../../types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { ADDON_IDS } from '../../addons'; +import { getClientIp } from '../audit/client-ip'; +import { DawarichService } from './dawarich.service'; +import { DawarichSyncService } from './dawarich-sync.service'; +import { DawarichSuggestionsService, AcceptError } from './dawarich-suggestions.service'; +import { DawarichTracksService } from './dawarich-tracks.service'; +import { DawarichError } from './dawarich.client'; +import { + DawarichAcceptDto, + DawarichAtlasAcceptDto, + DawarichBucketConfirmDto, + DawarichSettingsDto, + DawarichSuggestionStateDto, +} from './dawarich.dto'; + +/** + * `/api/integrations/dawarich` — the per-user Dawarich connection and everything + * it produces (#2279). + * + * Two shapes of answer, deliberately different: + * + * - **Connection routes** (`status`, `test`) answer 200 even when the instance + * is unreachable, with the reason in the body. A form that is showing someone + * their own typo needs a field to render, not an exception. + * - **Data routes** answer the status the failure deserves, because a caller + * that asked for suggestions and got an empty list would take it as "there + * are none". + * + * The whole group is gated on the `dawarich` addon, with `AddonGuard` ahead of + * `JwtAuthGuard` — the order every addon-gated controller in TREK uses, so that + * a switched-off addon answers the same 404 to everyone rather than 401 to some + * callers and 404 to others. It does mean an unauthenticated caller can learn + * whether the addon is on; that is true of every addon here and is not a secret + * worth a second guard order for. + */ +@Controller('api/integrations/dawarich') +@UseGuards(AddonGuard, JwtAuthGuard) +@RequireAddon(ADDON_IDS.DAWARICH, 'Dawarich') +export class DawarichController { + constructor( + private readonly dawarich: DawarichService, + private readonly sync: DawarichSyncService, + private readonly suggestions: DawarichSuggestionsService, + private readonly tracks: DawarichTracksService, + ) {} + + // ── Connection ───────────────────────────────────────────────────────────── + + @Get('settings') + getSettings(@CurrentUser() user: User) { + return this.dawarich.getConnection(user.id); + } + + @Put('settings') + async putSettings( + @CurrentUser() user: User, + @Body() body: DawarichSettingsDto, + @Req() req: Request, + ) { + const result = await this.dawarich.saveSettings( + user.id, + body.url, + body.apiKey, + !!body.allowInsecureTls, + body.syncEnabled !== false, + getClientIp(req), + ); + if (!result.success) { + throw new HttpException({ error: result.error }, 400); + } + // A changed address or key means every cached line was drawn from somewhere + // else. Keeping it would show the previous instance's route under the new + // connection's name. + this.tracks.forget(user.id); + return result.warning ? { success: true, warning: result.warning } : { success: true }; + } + + @Delete('settings') + @HttpCode(200) + disconnect(@CurrentUser() user: User, @Req() req: Request) { + this.dawarich.disconnect(user.id, getClientIp(req)); + this.tracks.forget(user.id); + return { success: true }; + } + + @Post('test') + @HttpCode(200) + test(@CurrentUser() user: User, @Body() body: DawarichSettingsDto) { + return this.dawarich.testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls); + } + + /** Pull now, rather than waiting for the next tick. */ + @Post('sync') + @HttpCode(200) + async syncNow(@CurrentUser() user: User) { + return this.sync.syncUser(user.id); + } + + // ── Suggestions ──────────────────────────────────────────────────────────── + + @Get('suggestions') + listSuggestions( + @CurrentUser() user: User, + @Query('tripId') tripId?: string, + @Query('state') state?: string, + ) { + return this.suggestions.list(user.id, { + tripId: parseOptionalId(tripId, 'tripId'), + state: parseState(state), + }); + } + + @Post('suggestions/:id/accept') + @HttpCode(200) + acceptSuggestion( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: DawarichAcceptDto, + @Req() req: Request, + ) { + return this.guard(() => + this.suggestions.accept(user.id, parseId(id), body, socketId(req)), + ); + } + + @Put('suggestions/:id/state') + @HttpCode(200) + setSuggestionState( + @CurrentUser() user: User, + @Param('id') id: string, + @Body() body: DawarichSuggestionStateDto, + ) { + const updated = this.suggestions.setState(user.id, parseId(id), body.state); + if (!updated) throw new HttpException({ error: 'Suggestion not found' }, 404); + return updated; + } + + // ── Bucket list ──────────────────────────────────────────────────────────── + + /** Ask the recordings whether the caller ever reached their own wishes. */ + @Post('bucket-list/scan') + @HttpCode(200) + async scanBucketList(@CurrentUser() user: User) { + return this.guardAsync(() => this.suggestions.scanBucketList(user.id)); + } + + @Post('bucket-list/confirm') + @HttpCode(200) + confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) { + return { updated: this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) }; + } + + @Delete('bucket-list/:itemId/visit') + @HttpCode(200) + clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) { + const cleared = this.suggestions.clearBucketVisit(user.id, parseId(itemId)); + if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404); + return { success: true }; + } + + // ── Atlas ────────────────────────────────────────────────────────────────── + + @Get('atlas/suggestions') + async atlasSuggestions( + @CurrentUser() user: User, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + const window = parseWindow(from, to); + return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to)); + } + + @Post('atlas/accept') + @HttpCode(200) + acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) { + return { marked: this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) }; + } + + // ── Track overlay ────────────────────────────────────────────────────────── + + /** + * The recorded route for a trip. Fetched from Dawarich per request and not + * stored — see DawarichTracksService. + */ + @Get('trips/:tripId/track') + async tripTrack( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('offset') offset?: string, + ) { + const track = await this.guardAsync(() => + this.tracks.forTrip(user.id, parseId(tripId), parseDate(from), parseDate(to), parseOffset(offset)), + ); + if (!track) throw new HttpException({ error: 'Trip not found' }, 404); + return track; + } + + /** The recorded route for an explicit range — what the journal asks for. */ + @Get('track') + async windowTrack( + @CurrentUser() user: User, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('offset') offset?: string, + ) { + const window = parseWindow(from, to); + return this.guardAsync(() => + this.tracks.forWindow( + user.id, + window.from.toISOString(), + window.to.toISOString(), + parseOffset(offset), + ), + ); + } + + // ── Error shaping ────────────────────────────────────────────────────────── + + /** + * One place where a domain error becomes an HTTP one. + * + * `DawarichError` keeps its own code in the body: the client renders these + * through i18n, and the upstream sentence would otherwise reach a German + * install in English. 502 rather than 500, because the failure is a server + * TREK called, not TREK itself. + */ + private guard(run: () => T): T { + try { + return run(); + } catch (err) { + throw toHttp(err); + } + } + + private async guardAsync(run: () => Promise): Promise { + try { + return await run(); + } catch (err) { + throw toHttp(err); + } + } +} + +function toHttp(err: unknown): HttpException { + if (err instanceof AcceptError) { + return new HttpException({ error: err.message, code: err.code }, err.status); + } + if (err instanceof DawarichError) { + return new HttpException( + { error: err.message, code: err.code, ...(err.detail ? { detail: err.detail } : {}) }, + err.code === 'unauthorized' || err.code === 'forbidden' ? 400 : 502, + ); + } + if (err instanceof HttpException) return err; + return new HttpException({ error: 'Dawarich request failed', code: 'server_error' }, 502); +} + +/** Ids are integers. `parseInt` would turn "12abc" into 12 and log a request nobody made. */ +function parseId(raw: string): number { + if (!/^\d+$/.test(raw)) throw new HttpException({ error: 'Invalid id' }, 400); + const id = Number(raw); + if (!Number.isSafeInteger(id) || id <= 0) throw new HttpException({ error: 'Invalid id' }, 400); + return id; +} + +function parseOptionalId(raw: string | undefined, label: string): number | undefined { + if (raw === undefined || raw === '') return undefined; + if (!/^\d+$/.test(raw)) throw new HttpException({ error: `Invalid ${label}` }, 400); + return Number(raw); +} + +/** An unknown state is a 400 rather than a silently unfiltered list. */ +function parseState(raw: string | undefined): string | undefined { + if (raw === undefined || raw === '') return undefined; + if (raw !== 'new' && raw !== 'accepted' && raw !== 'dismissed') { + throw new HttpException({ error: 'state must be one of: new, accepted, dismissed' }, 400); + } + return raw; +} + +/** + * The caller's UTC offset in minutes, as `Date.getTimezoneOffset()` negated — + * +120 for Berlin in summer. + * + * Points from Dawarich are bare instants, so "which day was that" can only be + * answered in somebody's timezone, and the browser is the only party that knows + * which one. Absent or unreadable means UTC, which is what the endpoint did + * before it asked. Bounded to the real range of world offsets (-12h..+14h) so a + * junk value cannot shift a day by a week. + */ +function parseOffset(raw: string | undefined): number { + if (raw === undefined || raw === '') return 0; + const value = Number(raw); + if (!Number.isFinite(value)) return 0; + return Math.max(-720, Math.min(840, Math.trunc(value))); +} + +function parseDate(raw: string | undefined): string | undefined { + if (raw === undefined || raw === '') return undefined; + if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + throw new HttpException({ error: 'Dates must be ISO, YYYY-MM-DD' }, 400); + } + return raw; +} + +/** + * An explicit instant window. Both ends are required together: a half-open + * range would silently become "everything since the epoch" on one side, which + * is the request that makes an instance time out. + */ +function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } { + const start = from ? new Date(from) : null; + const end = to ? new Date(to) : null; + if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) { + throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400); + } + if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400); + const MAX_DAYS = 400; + if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) { + throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400); + } + return { from: start, to: end }; +} + +/** The originating client's socket id, so a broadcast does not echo back to it. */ +function socketId(req: Request): string | undefined { + const header = req.headers['x-socket-id']; + return typeof header === 'string' && header ? header : undefined; +} diff --git a/server/src/nest/integrations/dawarich.dto.ts b/server/src/nest/integrations/dawarich.dto.ts new file mode 100644 index 000000000..00b802086 --- /dev/null +++ b/server/src/nest/integrations/dawarich.dto.ts @@ -0,0 +1,19 @@ +/** + * Server-side createZodDto wrappers over the @trek/shared Dawarich contracts, + * so the global ZodValidationPipe (APP_PIPE) validates bodies by metatype — + * the shared Zod schemas stay the single source of truth. + */ +import { createZodDto } from 'nestjs-zod'; +import { + dawarichAcceptSchema, + dawarichAtlasAcceptSchema, + dawarichBucketConfirmSchema, + dawarichSettingsSchema, + dawarichSuggestionStateSchemaBody, +} from '@trek/shared'; + +export class DawarichSettingsDto extends createZodDto(dawarichSettingsSchema) {} +export class DawarichAcceptDto extends createZodDto(dawarichAcceptSchema) {} +export class DawarichSuggestionStateDto extends createZodDto(dawarichSuggestionStateSchemaBody) {} +export class DawarichBucketConfirmDto extends createZodDto(dawarichBucketConfirmSchema) {} +export class DawarichAtlasAcceptDto extends createZodDto(dawarichAtlasAcceptSchema) {} diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..0285e252383bc3c871ab4384d563913b0c0e148b GIT binary patch literal 19215 zcmdU%|8gA1amVvFpJJ%Ej5`uIg0!XBkU$0!rAVy9Kbq-R*gIkKma` zN`K`65hj$7e)T|acBcEnhSyhZL4y$Z@`7~Q?uRnR2pU?7n(?a>jrphlL z)E8NmPu>aZ<+PYRFN=Az0qir7T2^`Y^!G(wG%vEN7X0YJB;qp~PYV`ijXMwS-MbU+ zh38BS7x{FNS9LfmtI%BJEwX{t^7ATdigF%)`2H_pUWQ55WT#opdVKl(@bI}l2-3Km zH(4>yt5Nv$bzZHSi(-BbxU312d|XWOI^4U`w=7ioxU436J4WgYU%uyG zI4ov)$mWwUEyvlEt*q+!$_M;y{@vCC*b4Xg}(=#VGUql(v_(fJsAtkVM{8=~=g}yDCi$9d}{6sLWvgveZ%&M0L zJS?V52V9<=)j7NfIRzkCxEc**_?Fpm_O zPgf=})uPH97aD^<&gPm}7aUr{m`PD*=T)AED<~~!lDQh~!cOdCldZxygIA?w(peqM zBV!w>zB&9P3{HOd{=fckG7J|S6w>JXl1LfOrO6pI-gcofJhMv{$!e{7j?m5boCIva zWYd;+mZ`I1;u4gGd*b5Ie zG7`sz-V1}T455Kfd2~GFa6Z@#_ru!rUmXuS>V0VntS6kaVmd8q^y++qPD54eBwZ20 zc^Ac*lql-lCC3Ttf`!CLl8uh*NBpebINl!N;ts?GqeWKLxlsq4Zy~+!=hf~mREnp6 z@-O`CJbSkD^y!J|ugJW&?cZ zQ=%$g8p{rbw=<`ccUDa1#rZ`O2J4CtAOSlTMHSZooue<8??ormcNU-+9GmPBmY+9BT!^4B{n-5<7dUscpG)9bvFwo%p;i2f6;*&rw~ko!Xmn6-D)9hcPfc`$eY#wgZ-=_nou2xv_lO zWNfX+IX-C^cKQz!?EXf-GC3I)^=HMr;4~+KH4MM}B_4*LJ`THxnGvljm-C4afNmc+ z%@Dtq1*HKGIkc-{J}IvPf|1pio%YX8%O&0h4pA0hl||CR-{Q#35hWa=)3UihspHse zyXOQ&C&k$rUJSk+rlUr=jKasbfOvxwMxEy43}Yi^(ekh5s9^@@lZE^`e4DA8u=$z^ z!**fy_CN%h$b!Lpr<{@-3l2@{UhqzH@O1~L;%zJKZYO7DWp)8oU(B`iDO@3UY##DA z7*F;C$+Bvtt%lFaY6I!GJR29UPc!tU_zvvx9E|Hbzv94XX7){ni81b}uy(n~=Q}*e zCm+gop65`&0G8$DDWsA^!A48`1CX1SSKGcdr(A{u0>Th?Z0@D)QL+_QIqnef%|tUG zdYHFcuzEEge`^OnKpPvYi62`7_GkEZI%Q>{{*E`Gwr32hVTh<<{}h)pF@1WTUEoEm zzd$hFnsIjsAHshA37_59?pY^84~c2fgvWc*bMlL~?VIbv-CYbN|Kt|zg|D)&T7$JI zJT__AS}<+6Gh!Cq?4t~#-gF#x*7gaPblL_ng$%B{blm%iayO8T6umxt^sBx1f6Ko$ zR2>4+xF4-wC+$oevPJ-6i1L}PY6iPu?2aPySMbW8C2YgRU2s>h}onc&QL8m z{;bqfv~p)fK1JjYUwryIxxrT+HfFBPlA3J2=$eBu=k#Q`m>^>2l$%3Je26A;;lDBy z#R53;Yt%#QN&L~MxWkieKI|xrK>t?yj4~MW#T2Q$;0xTyRs~N9yQNGl%% zjO~1zWnQmM>1AdWC(|)VNZ5B@A6_DWLr0vXD3RnaBj1 zL{{1*aVx<3K`SpXvlCAw2vm9c9W1k^?oR&s<=0=0+*>QoR?$^5e`^vJ*=ky5{3aaE zD+SP(`6{j`bc}NB<25Eq)G}NY8OZ1`qsXSkpLlasE`=#5R9Co=CPW0%mw61(jJn#R z9gYA%pR$dwH+=*f{yAqcuusx!o(%bY%uB3$XfE(<38ePJkqLNo&TRgum<**!Lu4>9 zLU{5dY;EOY0|de7FNGNB=oH9!&m+I!=yC!;C8P_vxG6@P4CIY0k-Ba2CI z)#F|?(506KTjYWsz5l`1aO6@mF#3$X!%Y}${oU4ZIGPma1kHo3i~P-27Z$jVS459} zbP-i(Xl@gx7iWf(H_sP2$+5&1-A0qnH}gYoji{Q^VGqm|-wVt6C8~4Yd3ZgurT9pR zFyz@rdkExMY_$f*;dScWh7QrOJ|`(x54UcD^KER&`LhWlwly0J!(D}2tn$`)rsb|0 zNlPZh_h61?8;WgW<1!|6qO^7RTmw0pi%$Q0Htis)BJy~yJW3C%U0F7GXLZt$C(eN< z?z2=ggi$#5HAGV_mHXNY?~y>8l3JU*$LW=e1>^EJMctS%&n22k#=^tV!$+fCQ%Bi6 z=HQldxkKl3TR<8Y_E0zoGvL%M&&5e(__o27oTb1;wJhh>{Y`vh*VL9PSwm9#u^TsT zY=<{P@_A`{{8Mgf;74?W4$iYyHu4Mh?`JJhk@WY!BcD1Li|3sKHEKFPzvXGv5eLlZ z0Gf(ee>7RLKj;zD2MLk^R@mM52ldboaxZK}Qj){PiQ9tpaJ;cqWLwQMI)h4R%>*wx zdh0JgMsX+f)&?xer>#CIC;4ZjgkD;P=Ek@+-%&zn3iJsY)Fcq^KF6MO@PGcZ^J)7^ zeB26V@&x;0EY*!2N@+EhvLu3lOY&*slGl%+*Se=MVf(h`x(ZbWZ-#OB8gf075%Nh$x;i!t)aLBsSG6z>!pINzi>_7IUvfsB3Ez#ro@B8fqwO=2q96-kC= z8#Wlu^A4Lc?vd}@-Br#Z`VAp$B%dtufTkyDs#$}S#ak{AusoZQHy{<^Rg>9*R4MWC zLh-$8WH*<}3a~eTWz#FUp_XALt4oU6&a5FjAhbpKQ|&qS%g%#SauP@RQCco|-?LcIK-6NH%;ET(=-Dac?y_$dm7 z&*=G@HZ~f%jg5A%;)>k^w|i?=qbR^)Wp9vu**SX{q%S)s4-L8Y?zH!=i%_Ho;1^8P3pB?EuYUCp54|Rl9yjF+ z!s(cGfBpq|ItqMxQ6jqW{X5y+; zRd;MH1{RPAS7RY2<5S>H2t}AIZiPS=5nbfj63w@vlkAkDqDSYfDT@Cu&NXo5~?n-$u?@5tK$0RDbfuT6EH$H z9+HsHC1f_$A<73G*t_%`Ie19rAbeza@ag{jgl3Xs=?#XXpe@5OmLk$>>IsSDJ}Qpy zCUW?`df&B$X2oYw0k2&~`Yo5Akp!>RQp(i;hq{eJ?W&2Cq;M!?6FD@CAM=SP3ilkh!r& z`@By-+nor=DUmu-`vL_mWF=<3YPF=`6nTAFMz0u4a1~KaV-jtrzRa(3f{=b4_x4i) zbHjEoY)YxT4Cqkd7Txv=r|7m9w^;#0nR7EmYr5N(l;lN){`?DRaE!oEA+uHPUD_66 zQy+~W(hj2C>!9On>GuSzdt7Ul;jX@b8?$Q7^4GhT7j$4I5^U?%IV5~70s`rtqM2wR z=Iw;~O}9MVFFal8=}J$fzBH0k-?gZQ_xju_FeA*xYEd==HY{L>jL(Nl+TBXlY$yz^ zE)V}qrt;x-7^ImpTP#Lkl_2N=*6e{H5nqC$J@rU0vh5}s1L$}^GzM@OF&aR;_-xg+ zNytWhk}VW(m(!IpXi8n5fai%H501>r*x$0+6hbMcas(Bbnq)E$f`VD4-EexSIZmj} zMtgFiqLN2u=IyJekED#hEyk8aGU)WSP<+LQ5kfMClS=i?WC;^I;XgD-^e@l`8es)ywKNc3=63k;VM3jvJbCoWYM;R1?xQ3$^Q(DT0c|2{5CDbhZ(_J~Dpx{)9p& zY3w$P24{1y&q+2}cHa$ClnsWr`ugN(IzN{MXpT<;k%wwWWV2|Jv%V?ikJO!a!~jOk z&yV5X`m$IoocY^iG430szgw#S=Rl>a;jsKNYw&;vJ=ecq68I^n>8=eR_3s`~4@#Hg zJL;*xpMMZh|FUU$t9`(+ARwi;vAjrR3SJ))rHzw-79AH^Uxy9h;Gs6M)f|*>w778y zN@jD&!!$$)Z~~@kEuRGI8cbBLz7MrdZ&i&qj>%4IO&n5|{_U<9zI&=zl%uchSm|XB zu53INi%(&_Dc+W{u8@td&294;cscZDB>f}d7qpk_O1zpZ5RfHUu*$601R-H+ne)`$LH(wsU`uLkqe)s9&-o~!>=`=u#tfTnBF4nb$ zxH}@dxch#%#uUe9?iZ&2al>3|nAj_b!%AQsL=`{5Ui+|h`^X49OU-U6+KQo{RnHw@ z?9h}^A-W@d*yaFAMG_-G> zYL2qm`qlNcvSchFY;@J~I5sru%-T~kmIR6&pW2w{3vKe=9-V22loO;t-6yfrGdN4d5)l{6s4+&cY7+$%Bp~+NW z`5D(bR!Yg?SWWQmw4pkhXr6STf46)jV|_F^j#&wRCw+B;Hg_GbL562-p>Q^W2s`1F zUy%}2R9?~pLOX?Y3cWV33ldyXKsZx-pjlCv8il+8%+*xLkNPEcb`Y=mucU?bj_AM_KFOjr#Tk-Al30^(B!AKCI z#@*28;(6{T;V3<8KcsgC)m6sT{21s^)D9{gsl^QY7>VTg+n$NmQ~E8fJep+-0z(wq zW(2Qn#L+@RPh2-bwHy=RMT-|}1~*K%mmPX+E<6&_8*ZFoq+^N9A2AeLIT*0@yIC;j zQG6Ss>nKs{l)P3+bM$;h(MgIhRUh;$W-3&^;#Dd^LN`3dyW%vL?~STpA1Vg&mf1i^ z8?;mTS)i1jk_+S6^i5PTFpjaAD)L4~(0=wvWJ|GzOmdQ4&jNdl@pGLG^#>DG+r<^@ zE2fFwlEj4I@&c%L(l(64vf~!RG*AC$9B438e%^5SV;<3= zjP;XS%Joq3Pq5#po9OKkDQ?zn3?hmf@1ZED?#X5zHDy-7)z zK{jt*`CI(B8$??l(%-DZP1;kWOU*^`G}0?Hrfs?J?-n^RBaC2jc(8E;$oGU=^S&xd z`O<#Y?2s5H@xl$;+8Wh`mKY^at*zXQ9;1DXHJW!@NH+qJ0o@dC2yeQ)Oh^$;#=DH7 zsGZEpnkjUgyKT2{;`aA~kBDD87&uV0rkhT)5x8J>Yg#0hhDt|ax;&h7ueAOUb&Yz? zq)(}FnR0E{lS5|BP%-56M#3cN*p+5UH`7Y@X33~hx@c#nm6A1egnf-C9Ukju)?%zq zz}*jrqdFJP!SwugKd{?8_h=c~gWYi1J4iV0acs&#Z5D=(T6bNU0@M&5yX`c($4A*Q zKu)=sh<`T7aKuk(hOq05x^$&4y4tuKZ&kQ|cVu2wYrx{<#51?MToF%nn8Jn5Egxj5 z`S768>b-C2TIDeMhu2;DKYUGQ!RR3V&^<2s9OgNwNliDe8k!Y2Uvds=VZ{}}iozvr zqRY}aUFykn|8o;+ZA6<$U6@i;E`{&R8EZ((^!<4)+k2#~aY{lRR!d6pv5PKZ?_q(Z zC~)CZlER#)E46y79+9{fA1J1wj4$`N;e`lfc|rv}h_l-=_Oj4C0u$n62Z#CK!%YT$I)4 z+*F*=*WDhx>n&$&%eTeGRQFm@iIv|8B+~SWCJxHr0TqCL_v3<~Q$8v86S2Zla!&Sm zBM^lkJ*FmWj?V-1UVNVJ4e~}QHzPsHCOYk$Dj&8t3=!c(xZcD2`~CZ5k%S!z9>w>XVHaP)^|8Gu`4QTVF1W`#ECesg8q&XI*P2`@_$?q(gE7jGk#(}%5>P0#iR#IT zi*%XK-TK#DT_T;Bl9^GJI6PAfCZw`bcGX6rsw<2NJ|yL>aiSxb2qE6d5Z){hWkTYm zLsOf+Oq`eE6iJ-K5&Dw{fgVw7L>4~nt$y?K1r~7ek0Cg;I$MpxbLvSPiDC!{haU8} zfl$K#cEOCl8xWbr?l++`X+?%<6hZJuB*Zf}-86zzX=}e%3Ku2!&ALMV8Y1Fs>xov_ zaV_IY<6|V!f%RK?3Qf`#y&d(`ns~cJQ}32vW?#BdE$o@w1Kp9+zT@NFU~nQQ+Y{}MnV zCCxE*jN;Wq(w2WvhbfzrpptcSc6&!Q2kF|#)%qRw!W%Qp23YrAC`-U7PSxbZ?Rn+xWM(;$P?gW>H!0N>4$FqW734gUGEH6-gMs7Ce|Y~rGqFn)Gc6Hxxtg(gFNPQ9hHYMywEpDh zsz}uO3l5t}lBe0~=y<(vDHi=Tc;r{iRCWs7wiAxqN;sQ~ZeQt#;rpm{spEt}z2rtg cd}^b4z}WaCMw0Q6x7)i5yP_b{etqY^0oBB3`v3p{ literal 0 HcmV?d00001 diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts new file mode 100644 index 000000000..c77b2b22f --- /dev/null +++ b/server/src/nest/integrations/dawarich.mcp.ts @@ -0,0 +1,240 @@ +import { + McpController, Tool, type McpContext, + TOOL_ANNOTATIONS_READONLY, + TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, errorResult, ok, +} from '../../nest-mcp'; +import { z } from 'zod'; +import { ADDON_IDS } from '../../addons'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { AuthService } from '../auth/auth.service'; +import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service'; +import { DawarichTracksService } from './dawarich-tracks.service'; + +/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */ +const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH); + +/** + * A stay list is short by nature — a day of travel produces a handful — but a + * year of them is not, and an assistant paying for the whole backlog on every + * turn is the failure mode. The browser can afford the full list because a + * human scrolls it. + */ +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +/** + * Dawarich MCP surface — the review half of the integration, mirroring + * `/api/integrations/dawarich/suggestions` and the track overlay. + * + * The scopes are chosen by **what each tool writes**, not by what domain the + * data came from, which is why accepting is three tools rather than one with a + * `target` parameter: creating a place is `places:write`, writing a journal + * entry is `journey:write`, and ticking off a wish is `atlas:write`. A single + * tool would have to claim the union of all three, and a caller that only + * wanted to fill in a journal would be holding a credential that can also edit + * an itinerary. + * + * Reading the suggestions themselves rides `journey:read`. They are candidate + * journal entries before they are anything else, and that is the narrowest + * existing scope that honestly covers "stays this person recorded". + * + * The connection routes (settings, test, disconnect) are deliberately absent: + * they take an API key for somebody's complete location history, and that is + * exactly what no assistant scope should be able to read or replace. + */ +@McpController() +export class DawarichMcp { + constructor( + private readonly suggestions: DawarichSuggestionsService, + private readonly tracks: DawarichTracksService, + private readonly auth: AuthService, + readonly addons: AddonsService, + ) {} + + @Tool({ + name: 'list_dawarich_suggestions', + description: + "List the stays TREK pulled from the caller's connected Dawarich instance and is holding for review: where they were, when they arrived and left, how long they stayed, and which trip the stay falls into. Each entry carries the id the accept tools take. Use it to answer what someone actually did on a trip, or to fill a travel journal from what was recorded rather than from memory. Nothing here has been added to a trip yet — accepting is a separate, explicit step.", + inputSchema: { + tripId: z.number().int().positive().optional().describe('Only stays that fall inside this trip'), + state: z.enum(['new', 'accepted', 'dismissed']).optional() + .describe('Default: every state. "new" is the review backlog.'), + limit: z.number().int().min(1).max(MAX_LIMIT).optional() + .describe(`Maximum stays to return, most recent first (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT})`), + }, + // Reads TREK's own table; the fetch from the remote instance happens on a cron. + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'journey', mode: 'read' }, + when: dawarichAddonOn, + }) + listSuggestions( + { tripId, state, limit }: { tripId?: number; state?: 'new' | 'accepted' | 'dismissed'; limit?: number }, + ctx: McpContext, + ) { + const all = this.suggestions.list(ctx.userId, { tripId, state }); + const cap = limit ?? DEFAULT_LIMIT; + const page = all.suggestions.slice(0, cap); + return ok({ + suggestions: page, + total: all.suggestions.length, + truncated: all.suggestions.length > page.length, + connected: all.connected, + lastSyncAt: all.lastSyncAt, + lastSyncState: all.lastSyncState, + }); + } + + @Tool({ + name: 'accept_dawarich_suggestion_as_place', + description: + 'Turn a reviewed Dawarich stay into a place on a trip, optionally pinned to one of its days. Call list_dawarich_suggestions first for the id. Correct the name or the coordinates in the same call if the detector got them wrong — that is what the review step is for. Use this when the stay is somewhere worth having on the itinerary; use accept_dawarich_suggestion_as_journal_entry when it is a moment worth writing about instead.', + inputSchema: { + suggestionId: z.number().int().positive(), + tripId: z.number().int().positive().optional().describe('Defaults to the trip the stay fell into'), + dayId: z.number().int().positive().optional().describe('Pin it to this day; omitted leaves it unplanned'), + name: z.string().trim().min(1).max(255).optional(), + notes: z.string().max(5000).optional(), + lat: z.number().min(-90).max(90).optional(), + lng: z.number().min(-180).max(180).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'places', mode: 'write' }, + when: dawarichAddonOn, + }) + acceptAsPlace( + args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + const { suggestionId, ...rest } = args; + return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest })); + } + + @Tool({ + name: 'accept_dawarich_suggestion_as_journal_entry', + description: + "Turn a reviewed Dawarich stay into a dated entry in a travel journal. Call list_dawarich_suggestions first for the id. The stay's own date, arrival time, name and coordinates prefill the entry; pass a title or a story to write it properly rather than leaving the detector's label. Use this to fill in a journal after a trip from what was actually recorded.", + inputSchema: { + suggestionId: z.number().int().positive(), + journalId: z.number().int().positive().describe('The journey the entry is added to'), + name: z.string().trim().min(1).max(255).optional().describe('Entry title; defaults to the stay name'), + notes: z.string().max(5000).optional().describe('The story text'), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + time: z.string().regex(/^\d{2}:\d{2}$/).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'journey', mode: 'write' }, + when: dawarichAddonOn, + }) + acceptAsJournalEntry( + args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + const { suggestionId, ...rest } = args; + return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest })); + } + + @Tool({ + name: 'mark_bucket_list_item_visited_from_dawarich', + description: + "Tick a bucket-list wish off because a recorded stay proves the caller got there. Call list_dawarich_suggestions first: a stay that sits on top of a wish already carries its id as matchedBucketListItemId. Use it when someone asks which of the places they wanted to see they have actually reached.", + inputSchema: { + suggestionId: z.number().int().positive(), + bucketListItemId: z.number().int().positive().optional() + .describe('Defaults to the wish the stay was matched to'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'atlas', mode: 'write' }, + when: dawarichAddonOn, + }) + markBucketVisited( + { suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + return this.run(() => + this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }), + ); + } + + @Tool({ + name: 'dismiss_dawarich_suggestion', + description: + 'Take a Dawarich stay out of the review list without adding it anywhere, or put a dismissed one back. Use it for the stays a detector produces that are not places anyone went — a traffic jam, a car park, the office.', + inputSchema: { + suggestionId: z.number().int().positive(), + state: z.enum(['dismissed', 'new']).optional().describe('Default: dismissed'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'journey', mode: 'write' }, + when: dawarichAddonOn, + }) + dismiss( + { suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed'); + if (!updated) return errorResult('Suggestion not found'); + return ok({ suggestion: updated }); + } + + @Tool({ + name: 'get_dawarich_trip_track', + description: + "Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. Nothing is stored in TREK — this is a live read of the recording.", + inputSchema: { + tripId: z.number().int().positive(), + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this first day'), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this last day'), + }, + // Reaches a remote instance rather than TREK's own database. + annotations: { ...TOOL_ANNOTATIONS_READONLY, openWorldHint: true }, + access: { group: 'journey', mode: 'read' }, + when: dawarichAddonOn, + }) + async tripTrack( + { tripId, from, to }: { tripId: number; from?: string; to?: string }, + ctx: McpContext, + ) { + let track; + try { + track = await this.tracks.forTrip(ctx.userId, tripId, from, to); + } catch (err) { + return errorResult(err instanceof Error ? err.message : 'Could not read the Dawarich recording'); + } + if (!track) return errorResult('Trip not found'); + // The geometry is the whole payload and a month of it is enormous in a tool + // result, so the summary goes out and the lines stay on the map. + return ok({ + source: track.source, + fetchedAt: track.fetchedAt, + truncated: track.truncated, + pointCount: track.pointCount, + days: track.days.map((day) => ({ + date: day.date, + segments: day.segments.map((segment) => ({ + mode: segment.mode, + startedAt: segment.startedAt, + endedAt: segment.endedAt, + distanceMeters: segment.distanceMeters, + points: segment.points.length, + })), + })), + }); + } + + /** The MCP echo of the controller's error shaping: a refusal, not an exception. */ + private run(action: () => T) { + try { + return ok(action() as object); + } catch (err) { + if (err instanceof AcceptError) return errorResult(err.message); + throw err; + } + } +} diff --git a/server/src/nest/integrations/dawarich.module.ts b/server/src/nest/integrations/dawarich.module.ts new file mode 100644 index 000000000..c416defab --- /dev/null +++ b/server/src/nest/integrations/dawarich.module.ts @@ -0,0 +1,66 @@ +import { Module } from '@nestjs/common'; +import { AddonsModule } from '../addons/addons.module'; +import { AuditModule } from '../audit/audit.module'; +import { AuthModule } from '../auth/auth.module'; +import { AtlasModule } from '../atlas/atlas.module'; +import { PermissionsModule } from '../permissions/permissions.module'; +import { PlacesModule } from '../places/places.module'; +import { AssignmentsDomainModule } from '../assignments/assignments-domain.module'; +import { JourneyDomainModule } from '../journey/journey-domain.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; +import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; +import { DawarichClient } from './dawarich.client'; +import { DawarichController } from './dawarich.controller'; +import { DawarichMcp } from './dawarich.mcp'; +import { DawarichService } from './dawarich.service'; +import { DawarichSuggestionsService } from './dawarich-suggestions.service'; +import { DawarichSyncJob } from './dawarich-sync.job'; +import { DawarichSyncService } from './dawarich-sync.service'; +import { DawarichTracksService } from './dawarich-tracks.service'; + +/** + * The Dawarich integration (#2279): a per-user connection to a self-hosted + * location-history instance, the visits it reports, and the recorded route + * drawn over a trip. + * + * Everything hangs off `/api/integrations/dawarich`, so there is no cycle to + * dodge and no split into a core module the way AirTrail needed — nothing in + * TREK injects Dawarich, Dawarich injects TREK. The direction of that arrow is + * the architecture: this module reads other domains' services to *create* what + * a user accepted, and never the other way round. + * + * The leaf modules are picked deliberately. `AssignmentsDomainModule` rather + * than `AssignmentsModule`, and `JourneyDomainModule` rather than + * `JourneyModule`, so accepting a suggestion does not drag two controller + * stacks and both photo providers into this graph. + */ +@Module({ + imports: [ + AddonsModule, + AuditModule, + // The MCP tools ask AuthService whether the caller is the demo user, the + // same gate every other write tool carries. + AuthModule, + AtlasModule, + // Accepting a stay writes a place and a day assignment, so it asks the same + // permissions the planner asks before doing either. + PermissionsModule, + PlacesModule, + AssignmentsDomainModule, + JourneyDomainModule, + SchedulingModule, + McpSharedModule, + ], + controllers: [DawarichController], + providers: [ + DawarichClient, + DawarichService, + DawarichSyncService, + DawarichSuggestionsService, + DawarichTracksService, + DawarichSyncJob, + DawarichMcp, + ], + exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService], +}) +export class DawarichModule {} diff --git a/server/src/nest/integrations/dawarich.service.ts b/server/src/nest/integrations/dawarich.service.ts new file mode 100644 index 000000000..20f0ecd30 --- /dev/null +++ b/server/src/nest/integrations/dawarich.service.ts @@ -0,0 +1,401 @@ +import { Injectable } from '@nestjs/common'; +import { + DAWARICH_KEY_MASK, + type DawarichCapabilities, + type DawarichConnection, + type DawarichStatus, + type DawarichSyncState, +} from '@trek/shared'; +import { DatabaseService } from '../database/database.service'; +import { AuditService } from '../audit/audit.service'; +import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { checkSsrf } from '../../utils/ssrfGuard'; +import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client'; + +/** + * The Dawarich connection: credentials, the probe, and what the connected + * instance can actually do. + * + * Credentials live in `dawarich_connections` rather than in more `users` + * columns. AirTrail put its four on `users` and said so out loud — correct while + * it was the only integration of that shape, and this is the second one. The + * deciding difference is that this connection carries state (last sync, last + * error, probed capabilities) that belongs to a sync, not to an identity, and + * that a personal location archive is the one credential worth keeping furthest + * from the row every other feature joins against. + * + * The key is encrypted at rest and never leaves again: `getConnection` returns a + * mask, and there is no route that reads it back. + */ +@Injectable() +export class DawarichService { + constructor( + private readonly db: DatabaseService, + private readonly audit: AuditService, + private readonly client: DawarichClient, + ) {} + + private readRow(userId: number): ConnRow | undefined { + return this.db.get( + `SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled, + last_sync_at, last_sync_state, last_sync_error, capabilities + FROM dawarich_connections WHERE user_id = ?`, + userId, + ); + } + + /** Decrypted credentials for an outbound call, or null when nothing is connected. */ + getCredentials(userId: number): DawarichCreds | null { + const row = this.readRow(userId); + if (!row?.url || !row?.api_key) return null; + const apiKey = decrypt_api_key(row.api_key); + if (!apiKey) return null; + return { baseUrl: row.url, apiKey, allowInsecureTls: !!row.allow_insecure_tls }; + } + + /** True when the user has both an address and a key on file. */ + isConnected(userId: number): boolean { + const row = this.readRow(userId); + return !!(row?.url && row?.api_key); + } + + /** Every user whose connection is usable and whose background sync is on. */ + listSyncableUserIds(): number[] { + const rows = this.db.all<{ user_id: number }>( + `SELECT user_id FROM dawarich_connections + WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL`, + ); + return rows.map((r) => r.user_id); + } + + /** The connection as the settings card shows it. The key is a mask, always. */ + getConnection(userId: number): DawarichConnection { + const row = this.readRow(userId); + return { + url: row?.url || '', + apiKeyMasked: row?.api_key ? DAWARICH_KEY_MASK : '', + allowInsecureTls: !!row?.allow_insecure_tls, + // A row that does not exist yet is a user who has never opened the card, + // and the default there is "poll once it is connected". + syncEnabled: row ? !!row.sync_enabled : true, + connected: !!(row?.url && row?.api_key), + lastSyncAt: row?.last_sync_at ?? null, + lastSyncState: normalizeSyncState(row?.last_sync_state), + lastSyncError: row?.last_sync_error ?? null, + capabilities: parseCapabilities(row?.capabilities), + }; + } + + getCapabilities(userId: number): DawarichCapabilities | null { + return parseCapabilities(this.readRow(userId)?.capabilities); + } + + /** + * Save the address and, when a new one was typed, the key. + * + * A blank key field or the mask means "keep what is stored" — the field is + * never prefilled, so blank is the normal state of a form someone opened to + * change the URL. + * + * A private address is saved with a warning rather than refused: a + * self-hosted Dawarich on a LAN is the common case, not an attack. Only a URL + * that could not work at all (malformed, unresolvable, not http) is rejected. + */ + async saveSettings( + userId: number, + url: string | undefined, + apiKey: string | undefined, + allowInsecureTls: boolean, + syncEnabled: boolean, + clientIp: string | null, + ): Promise<{ success: boolean; warning?: string; warningCode?: string; warningIp?: string; error?: string; code?: string }> { + const trimmedUrl = (url || '').trim(); + let warning: string | undefined; + let warningCode: string | undefined; + let warningIp: string | undefined; + + if (trimmedUrl) { + const ssrf = await checkSsrf(trimmedUrl); + if (!ssrf.allowed && !ssrf.isPrivate) { + // The sentence is kept for a caller reading the API directly; the code + // beside it is what the UI renders, in the reader's own language. + return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' }; + } + if (ssrf.isPrivate) { + this.audit.writeAudit({ + userId, + action: 'dawarich.private_ip_configured', + ip: clientIp, + details: { dawarich_url: trimmedUrl, resolved_ip: ssrf.resolvedIp }, + }); + warning = `Dawarich URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`; + warningCode = 'private_ip'; + warningIp = ssrf.resolvedIp ?? undefined; + } + } + + const previousUrl = this.db.get<{ url: string | null }>( + 'SELECT url FROM dawarich_connections WHERE user_id = ?', + userId, + )?.url ?? null; + + const provided = (apiKey || '').trim(); + const newKey = provided && provided !== DAWARICH_KEY_MASK ? maybe_encrypt_api_key(provided) : undefined; + + // All three writes or none: a row that took the new address but not the new + // key would point a credential minted for one instance at another one, and + // the next sync would send it there. + this.db.transaction(() => { + this.db.run( + `INSERT INTO dawarich_connections (user_id, url, allow_insecure_tls, sync_enabled, updated_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + url = excluded.url, + allow_insecure_tls = excluded.allow_insecure_tls, + sync_enabled = excluded.sync_enabled, + updated_at = CURRENT_TIMESTAMP`, + userId, + trimmedUrl || null, + allowInsecureTls ? 1 : 0, + syncEnabled ? 1 : 0, + ); + + if (newKey !== undefined) { + this.db.run('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?', newKey, userId); + } + + // A key belongs to the instance it was minted on. Pointing the connection + // at a different host without retyping the key would quietly send that + // 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)) { + this.db.run( + `UPDATE dawarich_connections + SET api_key = NULL, capabilities = NULL, last_sync_state = 'never', + last_sync_error = NULL, last_sync_at = NULL + WHERE user_id = ?`, + userId, + ); + } + + // Clearing the address leaves a key that can no longer be used for + // anything. Keeping it would be storing a live credential for a server + // nobody named. + if (!trimmedUrl) { + this.db.run( + `UPDATE dawarich_connections + SET api_key = NULL, capabilities = NULL, last_sync_state = 'never', + last_sync_error = NULL, last_sync_at = NULL + WHERE user_id = ?`, + userId, + ); + } + }); + + return warning ? { success: true, warning, warningCode, warningIp } : { success: true }; + } + + /** + * Forget the connection entirely. + * + * The suggestions it produced are deliberately left alone: one that was + * accepted became a place or a journal entry the user wrote, and deleting + * their journal because they revoked an API key would be the integration + * destroying user content on its way out. What goes is the credential. + */ + disconnect(userId: number, clientIp: string | null): void { + this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId); + this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} }); + } + + /** + * Try the connection as it is about to be saved — with the typed key if there + * is one, otherwise with the stored one. + * + * Answers 200 with `connected: false` rather than throwing, because "wrong + * key" is an answer the form has to render, not a server error. + */ + async testConnection( + userId: number, + url: string | undefined, + apiKey: string | undefined, + allowInsecureTls: boolean, + ): Promise { + 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; + + if (!baseUrl || !key) { + return { connected: false, error: 'not_connected' }; + } + + const creds: DawarichCreds = { baseUrl, apiKey: key, allowInsecureTls }; + try { + const capabilities = await this.probeCapabilities(creds); + // Only persist against a connection that is actually stored — a test run + // from a half-filled form must not overwrite what is on file. + if (stored && baseUrl === stored.baseUrl) this.storeCapabilities(userId, capabilities); + return { + connected: true, + visitCount: capabilities.visits ? await this.countRecentVisits(creds) : 0, + capabilities, + }; + } catch (err) { + if (err instanceof DawarichError) { + return { connected: false, error: err.code, ...(err.detail ? { errorDetail: err.detail } : {}) }; + } + return { connected: false, error: 'unreachable' }; + } + } + + /** + * Ask the instance what it offers, once, instead of assuming a version. + * + * Each endpoint is tried with a tiny window; a 404 means this build does not + * have it and the feature that needs it stays off, which is how an older + * Dawarich still connects and still does most of what people want. Anything + * other than a 404 — an unreachable host, a rejected key — is a real failure + * and propagates: reporting "connected, but nothing works" would be worse + * than the error. + */ + async probeCapabilities(creds: DawarichCreds): Promise { + const { version } = await this.client.probe(creds); + const to = new Date(); + const from = new Date(to.getTime() - 24 * 60 * 60 * 1000); + + const visitsProbe = await this.tryEndpoint(() => this.client.listVisits(creds, from, to)); + const tracks = await this.tryEndpoint(() => this.client.listTracks(creds, from, to)); + const points = await this.tryEndpoint(() => this.client.listPoints(creds, from, to)); + const visitedCities = await this.tryEndpoint(() => this.client.listVisitedCities(creds, from, to)); + // A coordinate no recording will ever be near: the probe is about whether + // the route answers, not about what it finds. + const locations = await this.tryEndpoint(() => this.client.findVisitsNear(creds, 0, 0, 50, 1)); + + const sample = visitsProbe.ok ? visitsProbe.value.visits[0] : undefined; + return { + visits: visitsProbe.ok, + tracks: tracks.ok, + points: points.ok, + locations: locations.ok, + visitedCities: visitedCities.ok, + // Read off a real row when there is one. With no visit in the probe window + // the honest answer is "not seen", and the sync re-probes on every run, so + // a later version's new field is picked up on its own. + visitUpdatedAt: !!sample && sample.updated_at != null, + visitCountryCode: !!sample?.place && sample.place.country_code != null, + serverVersion: version, + probedAt: new Date().toISOString(), + }; + } + + /** A 404 means "this build has no such endpoint"; everything else is a real failure. */ + private async tryEndpoint(run: () => Promise): Promise<{ ok: true; value: T } | { ok: false }> { + try { + return { ok: true, value: await run() }; + } catch (err) { + if (err instanceof DawarichError && err.code === 'not_found') return { ok: false }; + throw err; + } + } + + /** Visits in the last 30 days — the number the connection card shows as proof of life. */ + private async countRecentVisits(creds: DawarichCreds): Promise { + const to = new Date(); + const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); + try { + const { visits } = await this.client.listVisits(creds, from, to); + return visits.length; + } catch { + // The connection is already proven at this point; a count is a nicety. + return 0; + } + } + + storeCapabilities(userId: number, capabilities: DawarichCapabilities): void { + this.db.run( + 'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?', + JSON.stringify(capabilities), + userId, + ); + } + + recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): void { + this.db.run( + `UPDATE dawarich_connections + SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ?`, + new Date().toISOString(), + state, + error, + userId, + ); + } +} + +interface ConnRow { + user_id: number; + url: string | null; + api_key: string | null; + allow_insecure_tls: number | null; + sync_enabled: number | null; + last_sync_at: string | null; + last_sync_state: string | null; + last_sync_error: string | null; + capabilities: string | null; +} + +/** + * An unrecognised stored state reads as `never` rather than being passed + * through: the client switches on it, and a value it has no branch for would + * render as nothing at all. + */ +function normalizeSyncState(raw: string | null | undefined): DawarichSyncState { + return raw === 'ok' || raw === 'partial' || raw === 'failed' ? raw : 'never'; +} + +/** + * Capabilities are stored as JSON and read back defensively: the column is + * written by an older build's shape after an upgrade, and a malformed value + * means "not probed yet", never a crash on the settings page. + */ +function parseCapabilities(raw: string | null | undefined): DawarichCapabilities | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed !== 'object' || parsed === null) return null; + return { + visits: !!parsed.visits, + tracks: !!parsed.tracks, + points: !!parsed.points, + locations: !!parsed.locations, + visitedCities: !!parsed.visitedCities, + visitUpdatedAt: !!parsed.visitUpdatedAt, + visitCountryCode: !!parsed.visitCountryCode, + serverVersion: typeof parsed.serverVersion === 'string' ? parsed.serverVersion : null, + probedAt: typeof parsed.probedAt === 'string' ? parsed.probedAt : new Date(0).toISOString(), + }; + } catch { + return null; + } +} + +/** + * Did the connection move to a different Dawarich instance? + * + * 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 — a conservative answer that errs towards + * dropping the key rather than shipping it somewhere new. + */ +function movedHost(before: string | null, after: string): boolean { + if (!before || !after) return false; + try { + const a = new URL(before); + const b = new URL(after); + return a.origin !== b.origin; + } catch { + return before !== after; + } +} diff --git a/server/src/typings/express.d.ts b/server/src/typings/express.d.ts index 5d51ff72f..9faf52609 100644 --- a/server/src/typings/express.d.ts +++ b/server/src/typings/express.d.ts @@ -3,6 +3,7 @@ // (`(req as AuthRequest).user`, `getRequest()`) with // a single source of truth so downstream code can read `req.user` directly. +import type { PublicApiGrant } from '@trek/shared'; import type { User } from '../types'; declare module 'express-serve-static-core' { @@ -13,6 +14,16 @@ declare module 'express-serve-static-core' { * `optionalAuthenticate` to signal "checked, but unauthenticated". */ user?: User | null; + /** + * What the `/api/v1` integration key presented on this request may read. + * Set by `ApiTokenGuard`, and only there. + * + * Its own field rather than something folded into `user`: the grant belongs + * to the credential, not to the person, and a cookie session has no grant at + * all. Putting it on `user` would leak a token's restrictions into every + * other auth path that happens to read the same object. + */ + apiToken?: PublicApiGrant; } } diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts new file mode 100644 index 000000000..1d201cdf3 --- /dev/null +++ b/server/tests/e2e/dawarich.e2e.test.ts @@ -0,0 +1,345 @@ +/** + * Dawarich e2e — drives /api/integrations/dawarich through the REAL AddonGuard + * and the REAL JwtAuthGuard, over the real DI-native services (DatabaseModule + + * DawarichModule) against a temp SQLite db carrying the full schema. + * + * Two things are replaced and nothing else: + * + * - `AddonsService.isAddonEnabled`, because the gate is what is under test and + * an admin toggle is not worth seeding for it; + * - `DawarichClient`, because a test must never open a socket to somebody's + * location archive. Every method is a spy that fails loudly if a route this + * file exercises turns out to reach upstream after all. + * + * Covered: the 404-before-401 ordering of the addon gate, the unauthenticated + * 401, the shape of a connection nobody has configured, the two URL rejections + * that need no DNS, the suggestions envelope's `connected: false`, and the + * cross-user isolation — someone else's suggestion is invisible and untouchable + * through your own session, and answers the same 404 a missing one does. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number | string, userId: number) => + db + .prepare( + 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', + ) + .get(userId, tripId, userId), + isOwner: () => false, +})); + +const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); +vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() })); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { createUser } from '../helpers/factories'; +import { DawarichModule } from '../../src/nest/integrations/dawarich.module'; +import { DawarichClient } from '../../src/nest/integrations/dawarich.client'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { AddonsService } from '../../src/nest/addons/addons.service'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +/** + * Every outbound call an instance would ever receive, stubbed to throw. A route + * that unexpectedly reaches upstream fails this suite instead of quietly + * hanging on a DNS lookup in CI. + */ +function makeClientStub() { + const boom = (name: string) => vi.fn(() => { + throw new Error(`DawarichClient.${name} must not be called from the e2e suite`); + }); + return { + probe: boom('probe'), + listVisits: boom('listVisits'), + listTracks: boom('listTracks'), + listPoints: boom('listPoints'), + listVisitedCities: boom('listVisitedCities'), + findVisitsNear: boom('findVisitsNear'), + }; +} + +/** A suggestion row with only the NOT NULL columns filled in. */ +function seedSuggestion(userId: number, sourceVisitId: string, name: string): number { + const info = db + .prepare( + `INSERT INTO dawarich_visit_suggestions + (user_id, source_visit_id, name, lat, lng, started_at, ended_at, duration_minutes, local_date, source_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(userId, sourceVisitId, name, 48.8584, 2.2945, '2026-05-01T10:00:00+02:00', '2026-05-01T12:30:00+02:00', 150, '2026-05-01', 'hash-' + sourceVisitId); + return Number(info.lastInsertRowid); +} + +describe('Dawarich e2e (real addon gate + real auth guard + real services + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + let ownerId: number; + let strangerId: number; + let strangerSuggestionId: number; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, DawarichModule] }) + .overrideProvider(AddonsService) + .useValue({ isAddonEnabled }) + .overrideProvider(DawarichClient) + .useValue(makeClientStub()) + .compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate + // by metatype, exactly as they do under buildApp(). + nest.useGlobalPipes(new ZodValidationPipe()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + createTables(db as never); + runMigrations(db as never); + ownerId = createUser(db as never, { username: 'dawarich-owner', email: 'dawarich-owner@test.example' }).user.id; + strangerId = createUser(db as never, { username: 'dawarich-stranger', email: 'dawarich-stranger@test.example' }).user.id; + strangerSuggestionId = seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower'); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + isAddonEnabled.mockReturnValue(true); + db.prepare('DELETE FROM dawarich_connections').run(); + db.prepare('DELETE FROM dawarich_visit_suggestions WHERE user_id = ?').run(ownerId); + }); + + afterAll(async () => { + await app.close(); + }); + + // ── Auth + addon gate ──────────────────────────────────────────────────── + + it('DAWARICH-E2E-001: 401 without a session cookie', async () => { + const res = await request(server).get('/api/integrations/dawarich/settings'); + expect(res.status).toBe(401); + }); + + it('DAWARICH-E2E-002: a session on a disabled addon gets 404 with the Dawarich label, not a 403 that confirms the route', async () => { + isAddonEnabled.mockReturnValue(false); + const res = await request(server) + .get('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' }); + }); + + it('DAWARICH-E2E-003: the addon gate beats the auth guard — no cookie and no addon is still 404, so an anonymous probe learns nothing', async () => { + isAddonEnabled.mockReturnValue(false); + const res = await request(server).get('/api/integrations/dawarich/suggestions'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' }); + }); + + it('DAWARICH-E2E-004: a disabled addon 404s the write routes too, and nothing is persisted', async () => { + isAddonEnabled.mockReturnValue(false); + const res = await request(server) + .put('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)) + .send({ url: 'https://dawarich.example', apiKey: 'secret' }); + expect(res.status).toBe(404); + expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + }); + + it('DAWARICH-E2E-005: the addon flag is re-read per request — switching it back on reopens the route without a restart', async () => { + isAddonEnabled.mockReturnValue(false); + expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(404); + isAddonEnabled.mockReturnValue(true); + expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(200); + }); + + // ── Connection ─────────────────────────────────────────────────────────── + + it('DAWARICH-E2E-010: GET settings for a user who has never connected answers the empty connection, with the poll defaulted on', async () => { + const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + url: '', + // Never the key, and not even a placeholder when there is nothing stored. + apiKeyMasked: '', + allowInsecureTls: false, + // No row yet is a user who has not opened the card; the default there is + // "poll once it is connected", not "off". + syncEnabled: true, + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, + }); + }); + + it('DAWARICH-E2E-011: PUT settings with an unparseable URL is a 400 and writes nothing', async () => { + const res = await request(server) + .put('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)) + .send({ url: 'not a url', apiKey: 'secret' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid URL' }); + // Crucially: the key is not stored on a rejected address either. + expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + }); + + it('DAWARICH-E2E-012: PUT settings with a non-HTTP scheme is a 400 — file:// and friends never reach the fetch layer', async () => { + const res = await request(server) + .put('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)) + .send({ url: 'file:///etc/passwd' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Only HTTP and HTTPS URLs are allowed' }); + expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 }); + }); + + it('DAWARICH-E2E-013: PUT settings rejects a body the shared contract does not accept, before any of this runs', async () => { + const res = await request(server) + .put('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)) + .send({ apiKey: 'secret' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/^url: /); + }); + + it('DAWARICH-E2E-014: clearing the address saves the row, keeps the connection unconnected and persists the poll toggle', async () => { + const put = await request(server) + .put('/api/integrations/dawarich/settings') + .set('Cookie', sessionCookie(ownerId)) + .send({ url: '', syncEnabled: false }); + expect(put.status).toBe(200); + expect(put.body).toEqual({ success: true }); + + const row = db.prepare('SELECT url, api_key, sync_enabled FROM dawarich_connections WHERE user_id = ?').get(ownerId); + expect(row).toEqual({ url: null, api_key: null, sync_enabled: 0 }); + + const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); + expect(res.body.connected).toBe(false); + expect(res.body.syncEnabled).toBe(false); + expect(res.body.apiKeyMasked).toBe(''); + }); + + it('DAWARICH-E2E-015: DELETE settings answers 200 even when there was nothing to disconnect', async () => { + const res = await request(server).delete('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + }); + + // ── Suggestions ────────────────────────────────────────────────────────── + + it('DAWARICH-E2E-020: GET suggestions without a connection answers the envelope with connected:false — an empty list alone would read as "no stays"', async () => { + const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + suggestions: [], + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + }); + }); + + it('DAWARICH-E2E-021: an unknown state filter is a 400 rather than a silently unfiltered list', async () => { + const res = await request(server) + .get('/api/integrations/dawarich/suggestions?state=pending') + .set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'state must be one of: new, accepted, dismissed' }); + }); + + it('DAWARICH-E2E-022: a tripId that only starts with digits is a 400', async () => { + const res = await request(server) + .get('/api/integrations/dawarich/suggestions?tripId=12abc') + .set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Invalid tripId' }); + }); + + // ── Cross-user isolation ───────────────────────────────────────────────── + + it("DAWARICH-E2E-030: another user's suggestion never appears in your own list", async () => { + const mine = seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord'); + + const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId)); + expect(res.status).toBe(200); + const ids = res.body.suggestions.map((s: { id: number }) => s.id); + expect(ids).toEqual([mine]); + expect(ids).not.toContain(strangerSuggestionId); + // And the row really is there for the other user, so this is isolation and + // not an empty table. + expect(db.prepare('SELECT user_id FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) + .toEqual({ user_id: strangerId }); + }); + + it("DAWARICH-E2E-031: PUT state on another user's suggestion is a 404 — the same answer a missing id gets, so nothing is enumerable", async () => { + const res = await request(server) + .put(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/state`) + .set('Cookie', sessionCookie(ownerId)) + .send({ state: 'dismissed' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Suggestion not found' }); + expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) + .toEqual({ state: 'new' }); + + const missing = await request(server) + .put('/api/integrations/dawarich/suggestions/999999/state') + .set('Cookie', sessionCookie(ownerId)) + .send({ state: 'dismissed' }); + expect(missing.status).toBe(404); + expect(missing.body).toEqual(res.body); + }); + + it("DAWARICH-E2E-032: POST accept on another user's suggestion is a 404 with the domain's own code", async () => { + const res = await request(server) + .post(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/accept`) + .set('Cookie', sessionCookie(ownerId)) + .send({ target: 'place', tripId: 1 }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Suggestion not found', code: 'not_found' }); + expect(db.prepare('SELECT state, target FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId)) + .toEqual({ state: 'new', target: null }); + }); + + it('DAWARICH-E2E-033: your own suggestion is reachable through the same route, which is what makes the two 404s above meaningful', async () => { + const mine = seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay'); + + const res = await request(server) + .put(`/api/integrations/dawarich/suggestions/${mine}/state`) + .set('Cookie', sessionCookie(ownerId)) + .send({ state: 'dismissed' }); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(mine); + expect(res.body.state).toBe('dismissed'); + expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(mine)).toEqual({ state: 'dismissed' }); + }); +}); diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index cdef60425..9ae7d7e24 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -42,6 +42,10 @@ const RESET_TABLES = [ // one case into the next. 'roadtrip_day_tracks', 'roadtrip_vias', + // Dawarich (#2279). The suggestions reference places and bucket_list, so they + // are cleared before both; the connection row hangs off the user. + 'dawarich_visit_suggestions', + 'dawarich_connections', // Day content 'day_notes', 'todo_category_assignees', diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts index 754ebcc0c..01f774c7c 100644 --- a/server/tests/unit/nest/atlas.service.test.ts +++ b/server/tests/unit/nest/atlas.service.test.ts @@ -1271,9 +1271,19 @@ describe('atlas quirk fixes', () => { const result = atlas.countryPlaces(user.id, 'JP'); - // status rides along since #1048 — a manual mark is a visit even with no trips. - expect(result).toEqual({ places: [], trips: [], manually_marked: true, status: 'visited' }); - expect(atlas.countryPlaces(user.id, 'FR').manually_marked).toBe(false); + // status rides along since #1048 — a manual mark is a visit even with no + // trips; marked_source since #2279 says who put the mark there, and a row + // written before that column existed still reads as 'manual'. + expect(result).toEqual({ + places: [], + trips: [], + manually_marked: true, + marked_source: 'manual', + status: 'visited', + }); + const unmarked = atlas.countryPlaces(user.id, 'FR'); + expect(unmarked.manually_marked).toBe(false); + expect(unmarked.marked_source).toBeNull(); }); it('ATLAS-SVC-032: updateBucketItem persists lat/lng of exactly 0 (equator/prime meridian)', () => { diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts new file mode 100644 index 000000000..8e91fcdce --- /dev/null +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -0,0 +1,989 @@ +/** + * Unit tests for DawarichSuggestionsService — DAWARICH-SUG-001..030. + * + * This is the only service in the Dawarich domain that writes into TREK proper, + * and every write it makes crosses an ownership line: a suggestion belongs to + * one user, the trip it lands on belongs to a roster, the wish it ticks belongs + * to the caller alone. So the DB is real in-memory SQLite — the scoping lives in + * the SQL and a stub would assert nothing about it — while the four collaborators + * that would reach further (Atlas, Places, Assignments, Journey) are stubs, plus + * DawarichService and DawarichClient so no case can touch the network or a + * user's stored credentials. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup (real in-memory SQLite — same pattern as the other service tests) ── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: unknown, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: (tripId: unknown, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import type { DawarichConnection } from '@trek/shared'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createDay, addTripMember } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { + AcceptError, + DawarichSuggestionsService, +} from '../../../src/nest/integrations/dawarich-suggestions.service'; +import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import type { DawarichClient } from '../../../src/nest/integrations/dawarich.client'; +import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; +import type { PlacesService } from '../../../src/nest/places/places.service'; +import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service'; +import type { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; + +// ── Collaborator stubs ─────────────────────────────────────────────────────── +// +// `getCredentials` hands back a fixed pair rather than reading +// dawarich_connections: the encrypted-at-rest round trip is DawarichService's +// own business, and a case that needed it would be testing apiKeyCrypto. The +// client is a stub with no implementation of its own, which doubles as the +// assertion that nothing here reaches the network — an unstubbed call would +// return undefined and fail loudly rather than dial out. + +const CREDS = { baseUrl: 'https://dawarich.example.com', apiKey: 'test-key', allowInsecureTls: false }; + +const CONNECTION: DawarichConnection = { + url: CREDS.baseUrl, + apiKeyMasked: '********', + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: '2026-09-06T04:15:00Z', + lastSyncState: 'ok', + lastSyncError: null, + capabilities: null, +}; + +const dawarichStub = { + getConnection: vi.fn(), + getCredentials: vi.fn(), +}; +const clientStub = { + findVisitsNear: vi.fn(), + listVisitedCities: vi.fn(), +}; +const atlasStub = { markCountry: vi.fn() }; +// `broadcast` and `onCreated` are stubbed because an acceptance is a normal +// place write and has to behave like one: everyone on the trip is told, and a +// live journey mirrors it. Both are asserted rather than merely tolerated. +const placesStub = { create: vi.fn(), broadcast: vi.fn(), onCreated: vi.fn() }; +const assignmentsStub = { dayExists: vi.fn(), createAssignment: vi.fn(), broadcast: vi.fn() }; +// The instance's permission matrix, stubbed so a case can narrow place_edit or +// day_edit to the owner the way an admin would and watch a member be refused. +const permissionsStub = { checkPermission: vi.fn() }; +const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn() }; + +const dbs = new DatabaseService(testDb); +const svc = new DawarichSuggestionsService( + dbs, + dawarichStub as unknown as DawarichService, + clientStub as unknown as DawarichClient, + atlasStub as unknown as AtlasService, + placesStub as unknown as PlacesService, + assignmentsStub as unknown as AssignmentsService, + permissionsStub as unknown as PermissionsService, + journeyStub as unknown as JourneyDomainService, +); + +const CREATED_ENTRY_ID = 7702; + +/** + * The places stub writes a real row rather than inventing an id: + * `accepted_place_id` is a foreign key onto places, so a made-up number makes + * the acceptance fail on the constraint instead of on anything a case is about. + */ +function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng?: number }): { id: number } { + const category = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined; + const result = testDb + .prepare('INSERT INTO places (trip_id, name, lat, lng, category_id) VALUES (?, ?, ?, ?, ?)') + .run(Number(tripId), body.name ?? 'Imported stay', body.lat ?? null, body.lng ?? null, category?.id ?? null); + return { id: Number(result.lastInsertRowid) }; +} + +function armStubs(): void { + dawarichStub.getConnection.mockReset().mockReturnValue({ ...CONNECTION }); + dawarichStub.getCredentials.mockReset().mockReturnValue({ ...CREDS }); + clientStub.findVisitsNear.mockReset(); + clientStub.listVisitedCities.mockReset(); + // Answers true — it added the country — unless a case says otherwise. + atlasStub.markCountry.mockReset().mockReturnValue(true); + placesStub.create + .mockReset() + .mockImplementation((tripId: string, body: { name?: string; lat?: number; lng?: number }) => + insertPlaceRow(tripId, body), + ); + // Answered out of the same DB the cases seed: day ownership is the point of + // several of them, and a stub hardcoded to true would prove nothing about + // which trip the day was looked up against. + assignmentsStub.dayExists + .mockReset() + .mockImplementation( + (dayId: unknown, tripId: unknown) => + !!testDb.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId), + ); + placesStub.broadcast.mockReset(); + placesStub.onCreated.mockReset(); + assignmentsStub.createAssignment.mockReset(); + assignmentsStub.broadcast.mockReset(); + permissionsStub.checkPermission.mockReset().mockReturnValue(true); + journeyStub.canEdit.mockReset().mockReturnValue(true); + journeyStub.createEntry.mockReset().mockReturnValue({ id: CREATED_ENTRY_ID }); +} + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +interface SuggestionSeed { + userId: number; + tripId?: number | null; + name?: string; + lat?: number | null; + lng?: number | null; + startedAt?: string; + endedAt?: string; + durationMinutes?: number; + localDate?: string; + state?: string; + matchedBucketListItemId?: number | null; + sourceHash?: string; +} + +let _visitSeq = 0; + +function seedSuggestion(seed: SuggestionSeed): number { + _visitSeq++; + const result = testDb + .prepare( + `INSERT INTO dawarich_visit_suggestions + (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, + duration_minutes, local_date, state, matched_bucket_list_item_id, source_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + seed.userId, + `visit-${_visitSeq}`, + seed.tripId ?? null, + seed.name ?? 'Cafe Central', + seed.lat === undefined ? 48.2092 : seed.lat, + seed.lng === undefined ? 16.3658 : seed.lng, + seed.startedAt ?? '2026-09-01T09:30:00Z', + seed.endedAt ?? '2026-09-01T11:45:00Z', + seed.durationMinutes ?? 135, + seed.localDate ?? '2026-09-01', + seed.state ?? 'new', + seed.matchedBucketListItemId ?? null, + seed.sourceHash ?? `hash-${_visitSeq}`, + ); + return Number(result.lastInsertRowid); +} + +interface SuggestionRow { + id: number; + state: string; + target: string | null; + accepted_place_id: number | null; + accepted_journal_entry_id: number | null; + accepted_bucket_list_item_id: number | null; + source_hash: string; + accepted_hash: string | null; +} + +function rowOf(id: number): SuggestionRow { + return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as SuggestionRow; +} + +interface BucketRow { + id: number; + name: string; + visited_at: string | null; + visited_source: string | null; +} + +function seedBucketItem( + userId: number, + overrides: Partial<{ + name: string; + lat: number | null; + lng: number | null; + visitedAt: string | null; + visitedSource: string | null; + }> = {}, +): number { + const result = testDb + .prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng, visited_at, visited_source) VALUES (?, ?, ?, ?, ?, ?)', + ) + .run( + userId, + overrides.name ?? 'Zell am See', + overrides.lat === undefined ? 47.3232 : overrides.lat, + overrides.lng === undefined ? 12.7981 : overrides.lng, + overrides.visitedAt ?? null, + overrides.visitedSource ?? null, + ); + return Number(result.lastInsertRowid); +} + +function bucketOf(id: number): BucketRow { + return testDb.prepare('SELECT * FROM bucket_list WHERE id = ?').get(id) as BucketRow; +} + +/** + * The refusal itself, not just that one happened: every case here cares about + * the status the controller maps and the code the MCP tool renders, and + * `toThrow(AcceptError)` alone would pass on the wrong refusal. + */ +function refusalFrom(fn: () => unknown): AcceptError { + try { + fn(); + } catch (err) { + if (err instanceof AcceptError) return err; + throw err; + } + throw new Error('expected the call to throw an AcceptError, but it returned'); +} + +async function asyncRefusalFrom(fn: () => Promise): Promise { + try { + await fn(); + } catch (err) { + if (err instanceof AcceptError) return err; + throw err; + } + throw new Error('expected the call to reject with an AcceptError, but it resolved'); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + // dawarich_visit_suggestions is not in the shared helper's reset list, so it + // is cleared here rather than by widening a list every other suite shares. + testDb.exec('DELETE FROM dawarich_visit_suggestions'); + armStubs(); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── list / getOne ──────────────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — the review list', () => { + it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", () => { + const { user: mine } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' }); + const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); + + const list = svc.list(mine.id, {}); + + expect(list.suggestions.map((s) => s.id)).toEqual([mineId]); + expect(list.suggestions.map((s) => s.name)).not.toContain('Their Kitchen'); + // Asserted in both directions, so an id that happens to line up cannot carry it. + expect(svc.list(stranger.id, {}).suggestions.map((s) => s.id)).toEqual([strangerId]); + }); + + it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const strangerTrip = createTrip(testDb, stranger.id); + + const onTrip = seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' }); + const dismissed = seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' }); + seedSuggestion({ userId: user.id, tripId: otherTrip.id }); + seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id }); + + const byTrip = svc + .list(user.id, { tripId: trip.id }) + .suggestions.map((s) => s.id) + .sort((a, b) => a - b); + expect(byTrip).toEqual([onTrip, dismissed].sort((a, b) => a - b)); + + expect(svc.list(user.id, { tripId: trip.id, state: 'dismissed' }).suggestions.map((s) => s.id)).toEqual([ + dismissed, + ]); + // Another user's trip id is not a way in: the user clause still applies. + expect(svc.list(user.id, { tripId: strangerTrip.id }).suggestions).toEqual([]); + }); + + it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', () => { + const { user } = createUser(testDb); + seedSuggestion({ userId: user.id }); + + const list = svc.list(user.id, {}); + + expect(dawarichStub.getConnection).toHaveBeenCalledWith(user.id); + expect(list.connected).toBe(true); + expect(list.lastSyncAt).toBe('2026-09-06T04:15:00Z'); + expect(list.lastSyncState).toBe('ok'); + expect(list.lastSyncError).toBeNull(); + }); + + it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' }); + + expect(svc.getOne(user.id, theirs)).toBeNull(); + expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen'); + }); +}); + +// ── accept: place ──────────────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — accepting into a trip', () => { + it('DAWARICH-SUG-005: a trip the caller cannot see is a 404, and nothing is created', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const strangerTrip = createTrip(testDb, stranger.id); + const id = seedSuggestion({ userId: user.id }); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id })); + + expect(err.status).toBe(404); + expect(err.code).toBe('not_found'); + expect(placesStub.create).not.toHaveBeenCalled(); + expect(rowOf(id).state).toBe('new'); + }); + + it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const otherTrip = createTrip(testDb, user.id); + const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-09-01' }); + const id = seedSuggestion({ userId: user.id }); + + const err = refusalFrom(() => + svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: foreignDay.id }), + ); + + expect(err.status).toBe(400); + expect(err.code).toBe('day_not_on_trip'); + // The day was checked against the trip it is being attached to, not on its own. + expect(assignmentsStub.dayExists).toHaveBeenCalledWith(foreignDay.id, trip.id); + expect(placesStub.create).not.toHaveBeenCalled(); + expect(assignmentsStub.createAssignment).not.toHaveBeenCalled(); + expect(rowOf(id).state).toBe('new'); + }); + + it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const id = seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' }); + + const result = svc.accept(member.id, id, { + target: 'place', + tripId: trip.id, + dayId: day.id, + notes: 'long lunch', + }); + + expect(placesStub.create).toHaveBeenCalledWith( + String(trip.id), + expect.objectContaining({ + name: 'Cafe Central', + lat: 48.2092, + lng: 16.3658, + notes: 'long lunch', + place_time: '09:30', + end_time: '11:45', + duration_minutes: 135, + }), + ); + const placeId = result.createdPlaceId; + expect(placeId).not.toBeNull(); + expect(assignmentsStub.createAssignment).toHaveBeenCalledWith(day.id, placeId, null); + expect(result.createdJournalEntryId).toBeNull(); + expect(result.suggestion.state).toBe('accepted'); + + const row = rowOf(id); + expect(row.target).toBe('place'); + expect(row.accepted_place_id).toBe(placeId); + // The hash is frozen at acceptance, which is what makes "changed since" answerable. + expect(row.accepted_hash).toBe(row.source_hash); + expect(result.suggestion.sourceChanged).toBe(false); + }); + + it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id, tripId: null }); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place' })); + + expect(err.status).toBe(400); + expect(err.code).toBe('trip_required'); + expect(placesStub.create).not.toHaveBeenCalled(); + }); + + it("DAWARICH-SUG-009: accepting a stranger's suggestion is a 404 before any target is looked at", () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const theirs = seedSuggestion({ userId: stranger.id }); + + const err = refusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id })); + + expect(err.status).toBe(404); + expect(err.code).toBe('not_found'); + expect(placesStub.create).not.toHaveBeenCalled(); + }); +}); + +// ── accept: journal ────────────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — accepting into a journey', () => { + it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id }); + journeyStub.canEdit.mockReturnValue(false); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 })); + + expect(journeyStub.canEdit).toHaveBeenCalledWith(4242, user.id); + expect(err.status).toBe(404); + expect(err.code).toBe('journal_forbidden'); + expect(journeyStub.createEntry).not.toHaveBeenCalled(); + expect(rowOf(id).state).toBe('new'); + }); + + it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); + + const result = svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7'); + + expect(journeyStub.createEntry).toHaveBeenCalledWith( + 88, + user.id, + expect.objectContaining({ + type: 'entry', + title: 'Cafe Central', + entry_date: '2026-09-01', + entry_time: '09:30', + location_name: 'Cafe Central', + location_lat: 48.2092, + location_lng: 16.3658, + }), + 'socket-7', + ); + expect(result.createdJournalEntryId).toBe(CREATED_ENTRY_ID); + expect(result.createdPlaceId).toBeNull(); + + const row = rowOf(id); + expect(row.state).toBe('accepted'); + expect(row.target).toBe('journal'); + expect(row.accepted_journal_entry_id).toBe(CREATED_ENTRY_ID); + }); + + it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id }); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal' })); + + expect(err.status).toBe(400); + expect(err.code).toBe('journal_required'); + expect(journeyStub.canEdit).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id }); + journeyStub.createEntry.mockReturnValue(null); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 })); + + expect(err.status).toBe(404); + expect(rowOf(id).state).toBe('new'); + }); +}); + +// ── accept: bucket list ────────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — ticking off a wish', () => { + it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const theirWish = seedBucketItem(stranger.id, { name: 'Their Hallstatt' }); + const id = seedSuggestion({ userId: user.id }); + + const err = refusalFrom(() => + svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: theirWish }), + ); + + expect(err.status).toBe(404); + expect(err.code).toBe('not_found'); + expect(bucketOf(theirWish).visited_at).toBeNull(); + expect(rowOf(id).state).toBe('new'); + }); + + it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", () => { + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); + const id = seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' }); + + const result = svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + + const item = bucketOf(wish); + expect(item.visited_at).toBe('2026-09-01T09:30:00Z'); + expect(item.visited_source).toBe('dawarich'); + expect(result.bucketListItemId).toBe(wish); + expect(result.createdPlaceId).toBeNull(); + expect(rowOf(id).accepted_bucket_list_item_id).toBe(wish); + }); + + it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', () => { + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); + const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: wish }); + + const result = svc.accept(user.id, id, { target: 'bucket_list' }); + + expect(result.bucketListItemId).toBe(wish); + expect(bucketOf(wish).visited_source).toBe('dawarich'); + }); + + it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: null }); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' })); + + expect(err.status).toBe(400); + expect(err.code).toBe('bucket_required'); + }); +}); + +// ── accept twice / state ───────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — what an acceptance closes off', () => { + it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + + const first = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + expect(placesStub.create).toHaveBeenCalledTimes(1); + + const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })); + + expect(err.status).toBe(409); + expect(err.code).toBe('already_accepted'); + expect(placesStub.create).toHaveBeenCalledTimes(1); + // Still pointing at the first place, so the refusal cost nothing and created nothing. + expect(rowOf(id).accepted_place_id).toBe(first.createdPlaceId); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 }); + }); + + it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', () => { + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id); + const id = seedSuggestion({ userId: user.id }); + svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + + const back = svc.setState(user.id, id, 'new'); + + expect(back?.state).toBe('accepted'); + expect(rowOf(id).state).toBe('accepted'); + // Dismissing it is refused the same way — the tick it produced is its own thing now. + expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('accepted'); + expect(rowOf(id).target).toBe('bucket_list'); + }); + + it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id }); + + expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('dismissed'); + expect(svc.setState(user.id, id, 'new')?.state).toBe('new'); + }); + + it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const theirs = seedSuggestion({ userId: stranger.id }); + + expect(svc.setState(user.id, theirs, 'dismissed')).toBeNull(); + expect(rowOf(theirs).state).toBe('new'); + }); +}); + +// ── bucket-list confirmation ───────────────────────────────────────────────── + +describe('DawarichSuggestionsService — confirming a scan', () => { + it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', () => { + const { user } = createUser(testDb); + const open = seedBucketItem(user.id, { name: 'Hallstatt' }); + const alreadyTicked = seedBucketItem(user.id, { + name: 'Kyoto', + visitedAt: '2026-01-04T12:00:00Z', + visitedSource: 'manual', + }); + + const updated = svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z'); + + expect(updated).toBe(1); + expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z'); + expect(bucketOf(open).visited_source).toBe('dawarich'); + // The hand-ticked one keeps both its date and who decided it. + expect(bucketOf(alreadyTicked).visited_at).toBe('2026-01-04T12:00:00Z'); + expect(bucketOf(alreadyTicked).visited_source).toBe('manual'); + }); + + it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const mine = seedBucketItem(user.id); + const theirs = seedBucketItem(stranger.id); + + const updated = svc.confirmBucketVisits(user.id, [mine, theirs]); + + expect(updated).toBe(1); + expect(bucketOf(theirs).visited_at).toBeNull(); + expect(bucketOf(mine).visited_at).not.toBeNull(); + }); + + it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); + const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' }); + + expect(svc.clearBucketVisit(user.id, mine)).toBe(true); + expect(bucketOf(mine).visited_at).toBeNull(); + expect(bucketOf(mine).visited_source).toBeNull(); + + expect(svc.clearBucketVisit(user.id, theirs)).toBe(false); + expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z'); + }); +}); + +// ── bucket-list scan (client stubbed, never the network) ───────────────────── + +describe('DawarichSuggestionsService — scanning for stays', () => { + it('DAWARICH-SUG-025: a scan without a connection refuses before a single request', async () => { + const { user } = createUser(testDb); + seedBucketItem(user.id); + dawarichStub.getCredentials.mockReturnValue(null); + + const err = await asyncRefusalFrom(() => svc.scanBucketList(user.id)); + + expect(err.status).toBe(400); + expect(err.code).toBe('not_connected'); + expect(clientStub.findVisitsNear).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SUG-026: the longest stay wins over the nearest, and a wish without coordinates reports as skipped', async () => { + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); + seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null }); + clientStub.findVisitsNear.mockResolvedValue([ + { + timestamp: 1757000000, + distance_meters: 12, + points_count: 4, + // The bus stopping outside: nearest, and far too short to be the visit. + visit_details: { start_time: '2026-09-04T10:00:00Z', end_time: '2026-09-04T10:05:00Z', duration_minutes: 5 }, + }, + { + timestamp: 1757003600, + distance_meters: 180, + points_count: 42, + visit_details: { start_time: '2026-09-04T12:00:00Z', end_time: '2026-09-04T13:30:00Z', duration_minutes: 90 }, + }, + ]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.skippedWithoutCoordinates).toBe(1); + expect(scan.truncated).toBe(false); + expect(scan.matches).toHaveLength(1); + expect(scan.matches[0]).toMatchObject({ + itemId: wish, + name: 'Hallstatt', + alreadyVisited: false, + match: { at: '2026-09-04T12:00:00Z', minutes: 90, distanceMeters: 180, points: 42 }, + }); + }); + + it('DAWARICH-SUG-027: a wish whose lookup fails reads as "no match", not as a failed scan', async () => { + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockRejectedValue(new Error('upstream is down')); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches).toHaveLength(1); + expect(scan.matches[0].match).toBeNull(); + }); +}); + +// ── Atlas ──────────────────────────────────────────────────────────────────── + +describe('DawarichSuggestionsService — the Atlas hand-off', () => { + it('DAWARICH-SUG-028: acceptAtlasCountries marks every code through the Atlas with the dawarich source', () => { + const { user } = createUser(testDb); + + const marked = svc.acceptAtlasCountries(user.id, ['de', ' fr ', 'us']); + + expect(marked).toBe(3); + expect(atlasStub.markCountry).toHaveBeenCalledTimes(3); + expect(atlasStub.markCountry).toHaveBeenNthCalledWith(1, user.id, 'DE', 'dawarich'); + expect(atlasStub.markCountry).toHaveBeenNthCalledWith(2, user.id, 'FR', 'dawarich'); + expect(atlasStub.markCountry).toHaveBeenNthCalledWith(3, user.id, 'US', 'dawarich'); + }); + + it('DAWARICH-SUG-042: the count is what was added, not what was asked for', () => { + const { user } = createUser(testDb); + // A country already on the map is an INSERT OR IGNORE that changes nothing, + // and "2 countries added" for one country is a toast that lies. + atlasStub.markCountry.mockImplementation((_userId: number, code: string) => code === 'FR'); + + expect(svc.acceptAtlasCountries(user.id, ['de', 'fr'])).toBe(1); + }); + + it('DAWARICH-SUG-029: anything that is not a two-letter code is skipped rather than written', () => { + const { user } = createUser(testDb); + + const marked = svc.acceptAtlasCountries(user.id, ['XYZ', 'D', '', ' ', 'D1', 'at']); + + expect(marked).toBe(1); + expect(atlasStub.markCountry).toHaveBeenCalledTimes(1); + expect(atlasStub.markCountry).toHaveBeenCalledWith(user.id, 'AT', 'dawarich'); + }); + + it('DAWARICH-SUG-030: atlasSuggestions codes what it can, lists what it cannot, and flags what TREK already has', async () => { + const { user } = createUser(testDb); + testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE'); + clientStub.listVisitedCities.mockResolvedValue([ + { country: 'Germany', cities: [{ city: 'Berlin', stayed_for: 120, timestamp: 1757000000 }] }, + { country: 'France', cities: [] }, + { country: 'Absurdistan', cities: [] }, + ]); + + const suggestions = await svc.atlasSuggestions(user.id, new Date('2026-08-01'), new Date('2026-09-01')); + + expect(suggestions.countries.map((c) => c.countryCode)).toEqual(['DE', 'FR']); + expect(suggestions.countries[0]).toMatchObject({ sourceName: 'Germany', alreadyVisited: true }); + expect(suggestions.countries[0].cities[0]).toMatchObject({ name: 'Berlin', minutes: 120 }); + expect(suggestions.countries[1].alreadyVisited).toBe(false); + // A country TREK cannot code is still a country the user went to. + expect(suggestions.unresolved).toEqual(['Absurdistan']); + }); + // ── What an acceptance tells the rest of the trip ────────────────────────── + + it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); + + const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7'); + + expect(placesStub.broadcast).toHaveBeenCalledWith( + String(trip.id), + 'place:created', + { place: expect.objectContaining({ id: result.createdPlaceId }) }, + 'socket-7', + ); + // The journey mirror is the other half of what the places controller does. + expect(placesStub.onCreated).toHaveBeenCalledWith(String(trip.id), result.createdPlaceId); + }); + + it('DAWARICH-SUG-039: a member refused place_edit is refused here too', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'place_edit'); + + expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id })).toThrow(AcceptError); + // Nothing written, and the stay is still waiting rather than marked handled. + expect(placesStub.create).not.toHaveBeenCalled(); + expect(rowOf(id).state).toBe('new'); + }); + + it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'day_edit'); + + expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).toThrow(AcceptError); + expect(placesStub.create).not.toHaveBeenCalled(); + expect(rowOf(id).state).toBe('new'); + }); + + it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + const id = seedSuggestion({ userId: member.id, tripId: trip.id }); + + svc.accept(member.id, id, { target: 'place', tripId: trip.id }); + + expect(permissionsStub.checkPermission).toHaveBeenCalledWith('place_edit', 'user', owner.id, member.id, true); + }); + + it('DAWARICH-SUG-038: a place made from a recording says where it came from', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + + const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + + const place = testDb + .prepare('SELECT source FROM places WHERE id = ?') + .get(result.createdPlaceId) as { source: string | null }; + expect(place.source).toBe('dawarich'); + }); + + it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + assignmentsStub.createAssignment.mockReturnValue({ id: 4242, day_id: day.id }); + + svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9'); + + expect(assignmentsStub.broadcast).toHaveBeenCalledWith( + String(trip.id), + 'assignment:created', + { assignment: { id: 4242, day_id: day.id } }, + 'socket-9', + ); + }); + + it('DAWARICH-SUG-033: an assignment the domain refuses to create is not broadcast as one', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { date: '2026-09-01' }); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + assignmentsStub.createAssignment.mockReturnValue(undefined); + + svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }); + + expect(assignmentsStub.broadcast).not.toHaveBeenCalled(); + }); + + // ── An acceptance whose result was deleted ──────────────────────────────── + // + // Deleting the place is how somebody says "not that one" after the fact. The + // stay then has nothing to show for itself and belongs back in the review + // list; leaving it under "already dealt with" is a dead end, because the row + // points at a place that is gone and accepting it again is refused as a + // duplicate. + + it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + expect(rowOf(id).state).toBe('accepted'); + + testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); + + const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!; + expect(listed.state).toBe('new'); + expect(listed.target).toBeNull(); + expect(listed.acceptedPlaceId).toBeNull(); + // And it can be accepted again rather than answering 409 forever. + expect(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })).not.toThrow(); + }); + + it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + + const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!; + expect(listed.state).toBe('accepted'); + expect(listed.acceptedPlaceId).not.toBeNull(); + }); + + it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id, tripId: null }); + const journeyId = Number( + testDb + .prepare( + "INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)", + ) + .run(user.id).lastInsertRowid, + ); + const entryId = Number( + testDb + .prepare( + "INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'entry', 'Cafe', '2026-09-01', 0, 0)", + ) + .run(journeyId, user.id).lastInsertRowid, + ); + journeyStub.createEntry.mockReturnValue({ id: entryId }); + + svc.accept(user.id, id, { target: 'journal', journalId: journeyId }); + expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('accepted'); + + testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId); + + expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('new'); + }); + + it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', () => { + const { user: mine } = createUser(testDb); + const { user: theirs } = createUser(testDb); + const trip = createTrip(testDb, theirs.id); + const id = seedSuggestion({ userId: theirs.id, tripId: trip.id }); + const result = svc.accept(theirs.id, id, { target: 'place', tripId: trip.id }); + testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId); + + svc.list(mine.id, {}); + + expect(rowOf(id).state).toBe('accepted'); + }); +}); diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts new file mode 100644 index 000000000..4dbf9ec66 --- /dev/null +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -0,0 +1,828 @@ +/** + * DawarichSyncService against a real in-memory database. + * + * The service is almost entirely reconciliation SQL — insert-or-update keyed on + * (user, source visit id), a hash comparison that must not touch an accepted + * row, and a "what did the source stop listing" pass over a window. A mocked + * DatabaseService would assert that the right strings were handed along and + * prove nothing about what they do, so the schema comes from the real migration + * array and the rows are read back with plain SQL. + * + * The two collaborators are faked rather than built: the client because a unit + * test must not open a socket, and DawarichService because its credential path + * drags in at-rest crypto and its capability probe makes five more upstream + * calls that have nothing to do with reconciliation. The fake still writes + * `recordSyncResult` into `dawarich_connections`, so "the failure is stored" is + * asserted against the table the settings card reads, not against a spy alone. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup (real in-memory SQLite — same vi.hoisted pattern as atlas/immich) ── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + return { + testDb: db, + dbMock: { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }, + }; +}); + +vi.mock('../../../src/db/database', () => dbMock); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service'; +import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; +import type { DawarichClient, DawarichCreds, DawarichVisitRaw } from '../../../src/nest/integrations/dawarich.client'; +import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import type { DawarichCapabilities, DawarichSyncState } from '@trek/shared'; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +const CAPABILITIES: DawarichCapabilities = { + visits: true, + tracks: true, + points: true, + locations: false, + visitedCities: true, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-12T00:00:00.000Z', +}; + +const listVisits = vi.fn(); +const client = { listVisits } as unknown as DawarichClient; + +/** Reads the same two columns the real service reads, so a row without a key is null. */ +const getCredentials = vi.fn((userId: number): DawarichCreds | null => { + const row = testDb + .prepare('SELECT url, api_key, allow_insecure_tls FROM dawarich_connections WHERE user_id = ?') + .get(userId) as { url: string | null; api_key: string | null; allow_insecure_tls: number } | undefined; + if (!row?.url || !row?.api_key) return null; + return { baseUrl: row.url, apiKey: row.api_key, allowInsecureTls: !!row.allow_insecure_tls }; +}); + +/** Writes the result where the settings card reads it, exactly as the real service does. */ +const recordSyncResult = vi.fn((userId: number, state: DawarichSyncState, error: string | null): void => { + testDb + .prepare( + 'UPDATE dawarich_connections SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ? WHERE user_id = ?', + ) + .run(new Date().toISOString(), state, error, userId); +}); + +const listSyncableUserIds = vi.fn((): number[] => + ( + testDb + .prepare( + "SELECT user_id FROM dawarich_connections WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL", + ) + .all() as { user_id: number }[] + ).map((r) => r.user_id), +); + +const storeCapabilities = vi.fn(); +const probeCapabilities = vi.fn(async (): Promise => CAPABILITIES); + +const dawarich = { + listSyncableUserIds, + getCredentials, + recordSyncResult, + storeCapabilities, + probeCapabilities, +} as unknown as DawarichService; + +// Direct construction over the shared test connection — no TestingModule +// (repo convention for DI-native service unit tests). +const dbs = new DatabaseService(testDb); +const addons = new AddonsService(dbs); +const svc = new DawarichSyncService(dbs, addons, client, dawarich); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const DAY_MS = 86_400_000; + +/** A calendar date relative to today, so a fixture never ages out of the 400-day trip cut-off. */ +function dayOffset(days: number): string { + return new Date(Date.now() + days * DAY_MS).toISOString().slice(0, 10); +} + +/** The trip every case uses: finished, recent, comfortably inside the sync window. */ +const TRIP_START = dayOffset(-30); +const TRIP_END = dayOffset(-25); +/** A day inside that trip — every visit fixture happens here unless it says otherwise. */ +const VISIT_DAY = dayOffset(-28); + +/** Brandenburger Tor. Berlin throughout, so the resolved country is unambiguous. */ +const LAT = 52.5163; +const LNG = 13.3777; + +let USER = 0; +let TRIP = 0; + +function visit(over: Partial & { id: number | string }): DawarichVisitRaw { + return { + id: over.id, + area_id: null, + started_at: over.started_at ?? `${VISIT_DAY}T09:00:00Z`, + ended_at: over.ended_at ?? `${VISIT_DAY}T12:00:00Z`, + duration: over.duration ?? null, + name: over.name ?? 'Hotel Adlon', + status: over.status ?? 'suggested', + confidence: over.confidence ?? null, + confidence_band: over.confidence_band ?? null, + place: over.place !== undefined ? over.place : { latitude: LAT, longitude: LNG, id: 77 }, + }; +} + +interface SuggestionRow { + id: number; + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + state: string; + source_hash: string; + accepted_hash: string | null; + source_missing_at: string | null; + matched_bucket_list_item_id: number | null; + first_seen_at: string; + last_seen_at: string; +} + +function rows(userId = USER): SuggestionRow[] { + return testDb + .prepare('SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? ORDER BY id') + .all(userId) as SuggestionRow[]; +} + +function only(userId = USER): SuggestionRow { + const all = rows(userId); + expect(all).toHaveLength(1); + return all[0]; +} + +function connection(userId = USER): { last_sync_state: string; last_sync_error: string | null } { + return testDb + .prepare('SELECT last_sync_state, last_sync_error FROM dawarich_connections WHERE user_id = ?') + .get(userId) as { last_sync_state: string; last_sync_error: string | null }; +} + +function connect( + userId: number, + opts: { url?: string | null; apiKey?: string | null; syncEnabled?: boolean } = {}, +): void { + testDb + .prepare( + 'INSERT OR REPLACE INTO dawarich_connections (user_id, url, api_key, allow_insecure_tls, sync_enabled) VALUES (?, ?, ?, 0, ?)', + ) + .run( + userId, + opts.url === undefined ? 'https://dawarich.test' : opts.url, + opts.apiKey === undefined ? 'secret-key' : opts.apiKey, + opts.syncEnabled === false ? 0 : 1, + ); +} + +function bucketItem(name: string, lat: number, lng: number, userId = USER): number { + const res = testDb + .prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, ?, ?)') + .run(userId, name, lat, lng); + return Number(res.lastInsertRowid); +} + +/** Every window of the next run answers with exactly these visits. */ +function withVisits(...visits: DawarichVisitRaw[]): void { + listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' }); +} + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + // RESET_TABLES in tests/helpers/test-db.ts predates this domain and does not + // list its two tables; foreign keys are off during the reset, so rows would + // otherwise outlive their user and leak into the next case. + testDb.exec('DELETE FROM dawarich_visit_suggestions'); + testDb.exec('DELETE FROM dawarich_connections'); + vi.clearAllMocks(); + probeCapabilities.mockResolvedValue(CAPABILITIES); + + setAddonEnabled(testDb, 'dawarich', true); + USER = createUser(testDb).user.id; + TRIP = createTrip(testDb, USER, { start_date: TRIP_START, end_date: TRIP_END }).id; + connect(USER); +}); + +afterAll(() => { + testDb.close(); +}); + +// ── Creating suggestions ───────────────────────────────────────────────────── + +describe('DawarichSyncService — new visits', () => { + it('DAWARICH-SYNC-001: stores an unseen visit as a suggestion in state "new"', async () => { + withVisits(visit({ id: 501, name: 'Hotel Adlon', confidence: 0.82, confidence_band: 'high' })); + + const result = await svc.syncUser(USER); + + expect(result).toMatchObject({ state: 'ok', created: 1, updated: 0, missing: 0 }); + const row = only(); + expect(row.source_visit_id).toBe('501'); + expect(row.state).toBe('new'); + expect(row.trip_id).toBe(TRIP); + expect(row.name).toBe('Hotel Adlon'); + expect(row.lat).toBeCloseTo(LAT, 4); + expect(row.lng).toBeCloseTo(LNG, 4); + expect(row.duration_minutes).toBe(180); + expect(row.local_date).toBe(VISIT_DAY); + expect(row.source_status).toBe('suggested'); + expect(row.confidence).toBe(0.82); + expect(row.confidence_band).toBe('high'); + expect(row.source_hash).toMatch(/^[0-9a-f]{64}$/); + expect(row.source_missing_at).toBeNull(); + expect(row.accepted_hash).toBeNull(); + }); + + it('DAWARICH-SYNC-002: resolves the country from the coordinates when the source sends none', async () => { + withVisits(visit({ id: 502 })); + + await svc.syncUser(USER); + + expect(only().country_code).toBe('DE'); + }); + + it('DAWARICH-SYNC-003: prefers the country code the source sends over the resolved one', async () => { + withVisits(visit({ id: 503, place: { latitude: LAT, longitude: LNG, id: 77, country_code: 'at' } })); + + await svc.syncUser(USER); + + expect(only().country_code).toBe('AT'); + }); + + it('DAWARICH-SYNC-004: skips a payload entry that is not a usable visit', async () => { + withVisits(visit({ id: 504 }), { + id: 505, + area_id: null, + started_at: 'not-a-date', + ended_at: 'nope', + duration: null, + name: 'Broken', + status: null, + confidence: null, + confidence_band: null, + place: null, + }); + + const result = await svc.syncUser(USER); + + expect(result.created).toBe(1); + expect(rows()).toHaveLength(1); + }); +}); + +// ── Idempotency and change detection ───────────────────────────────────────── + +describe('DawarichSyncService — repeated runs', () => { + it('DAWARICH-SYNC-010: a second run over identical data creates no duplicate', async () => { + withVisits(visit({ id: 601 })); + await svc.syncUser(USER); + const first = only(); + + const second = await svc.syncUser(USER); + + expect(second).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); + const row = only(); + expect(row.id).toBe(first.id); + expect(row.first_seen_at).toBe(first.first_seen_at); + expect(row.source_hash).toBe(first.source_hash); + }); + + it('DAWARICH-SYNC-011: a changed visit rewrites a suggestion still in state "new"', async () => { + withVisits(visit({ id: 602, name: 'Unnamed place' })); + await svc.syncUser(USER); + const before = only(); + + withVisits(visit({ id: 602, name: 'Café Einstein', place: { latitude: 52.52, longitude: 13.38, id: 78 } })); + const second = await svc.syncUser(USER); + + expect(second).toMatchObject({ created: 0, updated: 1 }); + const row = only(); + expect(row.id).toBe(before.id); + expect(row.name).toBe('Café Einstein'); + expect(row.lat).toBeCloseTo(52.52, 4); + expect(row.source_hash).not.toBe(before.source_hash); + expect(row.state).toBe('new'); + }); + + it('DAWARICH-SYNC-012: a changed visit in state "accepted" keeps the user text and only moves the hash', async () => { + withVisits(visit({ id: 603, name: 'Hotel Adlon' })); + await svc.syncUser(USER); + const before = only(); + + // What acceptance leaves behind: the user's own wording plus the hash they said yes to. + testDb + .prepare( + "UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash, name = ? WHERE id = ?", + ) + .run('Our anniversary dinner', before.id); + + withVisits(visit({ id: 603, name: 'Adlon Kempinski', place: { latitude: 52.4, longitude: 13.2, id: 79 } })); + const second = await svc.syncUser(USER); + + expect(second).toMatchObject({ created: 0, updated: 1 }); + const row = only(); + expect(row.state).toBe('accepted'); + expect(row.name).toBe('Our anniversary dinner'); + expect(row.lat).toBeCloseTo(LAT, 4); + // sourceChanged is exactly this inequality, and the hash is the only thing that moved. + expect(row.source_hash).not.toBe(before.source_hash); + expect(row.accepted_hash).toBe(before.source_hash); + expect(row.source_hash).not.toBe(row.accepted_hash); + }); + + it('DAWARICH-SYNC-013: a changed visit in state "dismissed" is likewise left alone', async () => { + withVisits(visit({ id: 604, name: 'Petrol station' })); + await svc.syncUser(USER); + const before = only(); + testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'dismissed' WHERE id = ?").run(before.id); + + withVisits(visit({ id: 604, name: 'Aral Tankstelle' })); + const second = await svc.syncUser(USER); + + expect(second.updated).toBe(1); + const row = only(); + expect(row.state).toBe('dismissed'); + expect(row.name).toBe('Petrol station'); + expect(row.source_hash).not.toBe(before.source_hash); + }); + + it('DAWARICH-SYNC-014: an unchanged accepted row is not counted as an update', async () => { + withVisits(visit({ id: 605 })); + await svc.syncUser(USER); + testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash").run(); + + const second = await svc.syncUser(USER); + + expect(second).toMatchObject({ created: 0, updated: 0, missing: 0 }); + }); +}); + +// ── Disappearance ──────────────────────────────────────────────────────────── + +describe('DawarichSyncService — visits that vanish from the source', () => { + it('DAWARICH-SYNC-020: an untouched suggestion is deleted when the source stops listing it', async () => { + withVisits(visit({ id: 701 })); + await svc.syncUser(USER); + expect(rows()).toHaveLength(1); + + withVisits(); + const second = await svc.syncUser(USER); + + expect(second).toMatchObject({ created: 0, updated: 0, missing: 1 }); + expect(rows()).toHaveLength(0); + }); + + it('DAWARICH-SYNC-021: an accepted suggestion survives and is only stamped source_missing_at', async () => { + withVisits( + visit({ id: 702 }), + visit({ + id: 703, + started_at: `${VISIT_DAY}T14:00:00Z`, + ended_at: `${VISIT_DAY}T16:00:00Z`, + name: 'Museumsinsel', + }), + ); + await svc.syncUser(USER); + testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '703'").run(); + + withVisits(); + const second = await svc.syncUser(USER); + + expect(second.missing).toBe(2); + const surviving = only(); + expect(surviving.source_visit_id).toBe('703'); + expect(surviving.state).toBe('accepted'); + expect(surviving.name).toBe('Museumsinsel'); + expect(surviving.source_missing_at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('DAWARICH-SYNC-022: a second empty run does not move an existing source_missing_at', async () => { + withVisits(visit({ id: 704 })); + await svc.syncUser(USER); + testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '704'").run(); + + withVisits(); + await svc.syncUser(USER); + const firstStamp = only().source_missing_at; + expect(firstStamp).not.toBeNull(); + + await svc.syncUser(USER); + + expect(only().source_missing_at).toBe(firstStamp); + }); + + it('DAWARICH-SYNC-023: a visit that comes back clears the missing flag', async () => { + withVisits(visit({ id: 705 })); + await svc.syncUser(USER); + testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '705'").run(); + + withVisits(); + await svc.syncUser(USER); + expect(only().source_missing_at).not.toBeNull(); + + withVisits(visit({ id: 705 })); + await svc.syncUser(USER); + + expect(only().source_missing_at).toBeNull(); + }); + + it('DAWARICH-SYNC-024: a suggestion outside the fetched window is untouched by the reconciliation', async () => { + withVisits(visit({ id: 706 })); + await svc.syncUser(USER); + + // Same user and trip, but a start date years before the window this trip asks about. + testDb + .prepare( + `INSERT INTO dawarich_visit_suggestions + (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, duration_minutes, + local_date, source_status, state, source_hash) + VALUES (?, '999', ?, 'Ancient stay', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 120, + '2019-01-01', 'suggested', 'new', 'deadbeef')`, + ) + .run(USER, TRIP, LAT, LNG); + + withVisits(); + const second = await svc.syncUser(USER); + + expect(second.missing).toBe(1); + expect(only().source_visit_id).toBe('999'); + }); +}); + +// ── Bucket-list matching ───────────────────────────────────────────────────── + +describe('DawarichSyncService — bucket-list matching', () => { + it('DAWARICH-SYNC-030: links a wish that is both close enough and dwelt on long enough', async () => { + const wish = bucketItem('Brandenburger Tor', LAT + 0.001, LNG); // ~111 m + withVisits(visit({ id: 801, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T10:00:00Z` })); + + await svc.syncUser(USER); + + expect(only().matched_bucket_list_item_id).toBe(wish); + }); + + it('DAWARICH-SYNC-031: refuses a wish that is close but whose stay is too short', async () => { + bucketItem('Brandenburger Tor', LAT + 0.001, LNG); + // Ten minutes, under DAWARICH_BUCKET_MATCH_MIN_MINUTES. + withVisits(visit({ id: 802, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T09:10:00Z` })); + + await svc.syncUser(USER); + + const row = only(); + expect(row.duration_minutes).toBe(10); + expect(row.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-032: refuses a long stay that is inside the coarse box but beyond the radius', async () => { + bucketItem('Reichstag', LAT + 0.008, LNG); // ~890 m: inside the prefilter box, outside 250 m + withVisits(visit({ id: 803 })); + + await svc.syncUser(USER); + + expect(only().matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-033: picks the nearest wish when several are in range', async () => { + const far = bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m + const near = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m + + withVisits(visit({ id: 804 })); + await svc.syncUser(USER); + + const matched = only().matched_bucket_list_item_id; + expect(matched).toBe(near); + expect(matched).not.toBe(far); + }); + + it('DAWARICH-SYNC-034: ignores a wish belonging to another user', async () => { + const other = createUser(testDb).user.id; + bucketItem('Brandenburger Tor', LAT + 0.0005, LNG, other); + + withVisits(visit({ id: 805 })); + await svc.syncUser(USER); + + expect(only().matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-035: a visit without coordinates matches nothing', async () => { + bucketItem('Brandenburger Tor', LAT, LNG); + withVisits(visit({ id: 806, place: null })); + + await svc.syncUser(USER); + + const row = only(); + expect(row.lat).toBeNull(); + expect(row.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-037: one wish, one stay — the nearer of two neighbours keeps it', async () => { + // 250 m is a city block, and a block in a city centre holds a dozen stays. + // The café across the square from the museum satisfies the radius exactly as + // the museum does; showing the same wish on both turns one achievement into + // two claims and invites ticking it off from the wrong one. + const wish = bucketItem('Museum Ludwig', LAT, LNG); + withVisits( + visit({ id: 810, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }), + visit({ id: 811, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }), + ); + + await svc.syncUser(USER); + + const all = rows(); + expect(all.find((r) => r.source_visit_id === '811')!.matched_bucket_list_item_id).toBe(wish); + expect(all.find((r) => r.source_visit_id === '810')!.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-038: the order the visits arrive in does not decide who keeps the wish', async () => { + const wish = bucketItem('Museum Ludwig', LAT, LNG); + withVisits( + visit({ id: 821, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }), + visit({ id: 822, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }), + ); + + await svc.syncUser(USER); + + const all = rows(); + expect(all.find((r) => r.source_visit_id === '821')!.matched_bucket_list_item_id).toBe(wish); + expect(all.find((r) => r.source_visit_id === '822')!.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-036: a suggestion still in state "new" is re-matched on a later run', async () => { + withVisits(visit({ id: 807 })); + await svc.syncUser(USER); + expect(only().matched_bucket_list_item_id).toBeNull(); + + const wish = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); + withVisits(visit({ id: 807, name: 'Brandenburg Gate' })); + await svc.syncUser(USER); + + expect(only().matched_bucket_list_item_id).toBe(wish); + }); +}); + +// ── syncUser result states ─────────────────────────────────────────────────── + +describe('DawarichSyncService — syncUser result state', () => { + /** A second, older trip so one run covers two windows. */ + function secondTrip(): number { + return createTrip(testDb, USER, { start_date: dayOffset(-60), end_date: dayOffset(-55) }).id; + } + + it('DAWARICH-SYNC-040: reports "partial" when one of two trips fails', async () => { + secondTrip(); + listVisits + .mockRejectedValueOnce(new DawarichError('unreachable', 'connect ECONNREFUSED')) + .mockResolvedValueOnce({ visits: [visit({ id: 901 })], truncated: false, version: null }); + + const result = await svc.syncUser(USER); + + expect(listVisits).toHaveBeenCalledTimes(2); + expect(result.state).toBe('partial'); + expect(recordSyncResult).toHaveBeenCalledWith(USER, 'partial', 'unreachable'); + expect(connection()).toMatchObject({ last_sync_state: 'partial', last_sync_error: 'unreachable' }); + }); + + it('DAWARICH-SYNC-041: reports "failed" when every trip fails', async () => { + secondTrip(); + listVisits.mockRejectedValue(new DawarichError('unauthorized', 'bad key')); + + const result = await svc.syncUser(USER); + + expect(listVisits).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); + expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'unauthorized' }); + }); + + it('DAWARICH-SYNC-042: a throw that is not a DawarichError is recorded as "unreachable"', async () => { + listVisits.mockRejectedValue(new TypeError('fetch failed')); + + const result = await svc.syncUser(USER); + + expect(result.state).toBe('failed'); + expect(connection().last_sync_error).toBe('unreachable'); + }); + + it('DAWARICH-SYNC-043: reports "ok" and clears the stored error on a clean run', async () => { + testDb + .prepare( + "UPDATE dawarich_connections SET last_sync_state = 'failed', last_sync_error = 'unreachable' WHERE user_id = ?", + ) + .run(USER); + withVisits(visit({ id: 902 })); + + const result = await svc.syncUser(USER); + + expect(result.state).toBe('ok'); + expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + }); + + it('DAWARICH-SYNC-044: a user with no syncable trip is "ok", not a failure', async () => { + testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP); + + const result = await svc.syncUser(USER); + + expect(result).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); + expect(listVisits).not.toHaveBeenCalled(); + expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + }); + + it('DAWARICH-SYNC-045: an archived trip is not polled', async () => { + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(TRIP); + + const result = await svc.syncUser(USER); + + expect(result.state).toBe('ok'); + expect(listVisits).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-046: a trip the user is only a member of is polled too', async () => { + const owner = createUser(testDb).user.id; + const shared = createTrip(testDb, owner, { start_date: dayOffset(-12), end_date: dayOffset(-10) }).id; + testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(shared, USER, owner); + testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP); + withVisits(); + + const result = await svc.syncUser(USER); + + expect(result.state).toBe('ok'); + expect(listVisits).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-SYNC-047: capabilities are re-probed after a run that was not a total failure', async () => { + withVisits(visit({ id: 903 })); + + await svc.syncUser(USER); + + expect(probeCapabilities).toHaveBeenCalledTimes(1); + expect(storeCapabilities).toHaveBeenCalledWith(USER, CAPABILITIES); + }); + + it('DAWARICH-SYNC-048: a failed probe does not turn a good sync into a failure', async () => { + withVisits(visit({ id: 904 })); + probeCapabilities.mockRejectedValue(new DawarichError('server_error', 'boom')); + + const result = await svc.syncUser(USER); + + expect(result.state).toBe('ok'); + expect(storeCapabilities).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-049: a failed run is not followed by a probe', async () => { + listVisits.mockRejectedValue(new DawarichError('unreachable', 'down')); + + await svc.syncUser(USER); + + expect(probeCapabilities).not.toHaveBeenCalled(); + }); +}); + +// ── Gates ──────────────────────────────────────────────────────────────────── + +describe('DawarichSyncService — gates', () => { + it('DAWARICH-SYNC-050: without the addon the run is stored as failed/addon_disabled', async () => { + setAddonEnabled(testDb, 'dawarich', false); + + const result = await svc.syncUser(USER); + + expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); + expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'addon_disabled'); + expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'addon_disabled' }); + expect(listVisits).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-051: without a connection the run is stored as failed/not_connected', async () => { + connect(USER, { apiKey: null }); + + const result = await svc.syncUser(USER); + + expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 }); + expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'not_connected'); + expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'not_connected' }); + expect(listVisits).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-052: syncGloballyEnabled follows the addon row', () => { + expect(svc.syncGloballyEnabled()).toBe(true); + setAddonEnabled(testDb, 'dawarich', false); + expect(svc.syncGloballyEnabled()).toBe(false); + }); +}); + +// ── runSync (the cron entry point) ─────────────────────────────────────────── + +describe('DawarichSyncService — runSync', () => { + it('DAWARICH-SYNC-060: does nothing at all while the addon is off', async () => { + setAddonEnabled(testDb, 'dawarich', false); + + await svc.runSync(); + + expect(listSyncableUserIds).not.toHaveBeenCalled(); + expect(recordSyncResult).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-061: one user blowing up does not stop the next one', async () => { + const other = createUser(testDb).user.id; + createTrip(testDb, other, { start_date: TRIP_START, end_date: TRIP_END }); + connect(other); + withVisits(); + // A hard throw, i.e. the case syncUser does not catch for itself. + getCredentials.mockImplementationOnce(() => { + throw new Error('credential store exploded'); + }); + + await svc.runSync(); + + expect(listSyncableUserIds).toHaveBeenCalledTimes(1); + expect(recordSyncResult).toHaveBeenCalledWith(other, 'ok', null); + }); + + it('DAWARICH-SYNC-062: skips a connection whose background sync is switched off', async () => { + connect(USER, { syncEnabled: false }); + withVisits(); + + await svc.runSync(); + + expect(recordSyncResult).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SYNC-063: a second tick while one is still running is dropped', async () => { + let release: (value: { visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }) => void = () => {}; + listVisits.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = svc.runSync(); + const second = svc.runSync(); + release({ visits: [], truncated: false, version: null }); + await Promise.all([first, second]); + + expect(listSyncableUserIds).toHaveBeenCalledTimes(1); + expect(listVisits).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-SYNC-064: the guard is released again, so the next tick runs', async () => { + withVisits(); + + await svc.runSync(); + await svc.runSync(); + + expect(listSyncableUserIds).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-SYNC-065: a second "check now" while one is running is answered, not started', async () => { + // The module flag only guards the cron against itself; the button calls + // syncUser directly, so without a per-user guard a held-down button asks + // the same instance for the same windows several times over. + withVisits(visit({ id: 901 })); + const first = svc.syncUser(USER); + const second = await svc.syncUser(USER); + + expect(second.alreadyRunning).toBe(true); + expect(second).toMatchObject({ created: 0, updated: 0, missing: 0 }); + await first; + // And the guard is released, so the next press does run. + expect((await svc.syncUser(USER)).alreadyRunning).toBeUndefined(); + }); +}); diff --git a/server/tests/unit/nest/dawarich.client.test.ts b/server/tests/unit/nest/dawarich.client.test.ts new file mode 100644 index 000000000..1a54e9270 --- /dev/null +++ b/server/tests/unit/nest/dawarich.client.test.ts @@ -0,0 +1,540 @@ +/** + * DawarichClient — what it puts on the wire, and what it makes of what comes + * back. + * + * The client is the only place TREK talks to a user's Dawarich instance, so + * both halves of that contract are pinned here: the URL and headers it builds + * (a pasted `/api` must not become `/api/api/v1`, and the API key must never + * reach a query string, where every proxy log between here and there would keep + * it), and the error code it hands back for each way a self-hosted instance can + * disappoint — a wrong key, a Cloudflare 403, a reverse proxy answering with an + * HTML login page. + * + * `safeFetch` and `readCappedJson` are mocked: the SSRF guard does a real DNS + * lookup and the capped reader wants a real stream, and neither is what this + * file is about. The readCappedJson stub still parses the bytes the response + * hands it, so "Dawarich answered HTML" stays a genuine parse failure rather + * than a mock returning `undefined` on command. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { safeFetchMock, readCappedJsonMock } = vi.hoisted(() => ({ + safeFetchMock: vi.fn(), + readCappedJsonMock: vi.fn(), +})); +vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetch: safeFetchMock })); +vi.mock('../../../src/utils/cappedFetch', () => ({ readCappedJson: readCappedJsonMock })); + +import { + DawarichClient, + DawarichError, + isoUtc, + unixSeconds, + POINTS_PAGE_SIZE, + TRACKS_PAGE_SIZE, + VISITS_PAGE_SIZE, + type DawarichCreds, + type DawarichVisitRaw, +} from '../../../src/nest/integrations/dawarich.client'; + +const API_KEY = 'sk-dawarich-2f7c9'; + +const CREDS: DawarichCreds = { + baseUrl: 'https://daw.example.org', + apiKey: API_KEY, + allowInsecureTls: false, +}; + +const FROM = new Date('2026-09-01T00:00:00.000Z'); +const TO = new Date('2026-09-08T00:00:00.000Z'); + +/** The bits of a Response this client touches, plus the bytes the reader sees. */ +interface Reply { + ok: boolean; + status: number; + headers: Headers; + text(): Promise; +} + +interface Recorded { + url: string; + init: RequestInit; + options?: { rejectUnauthorized?: boolean }; +} + +let calls: Recorded[] = []; + +function reply(opts: { + status?: number; + body?: unknown; + /** Raw bytes, for the answers that are not JSON at all. */ + raw?: string; + headers?: Record; +}): Reply { + const status = opts.status ?? 200; + const raw = opts.raw ?? JSON.stringify(opts.body ?? {}); + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(opts.headers ?? {}), + text: async () => raw, + }; +} + +/** + * Queue the answers, in order. The last one repeats, which is what the + * pagination cases want: twenty identical pages are one `reply`. + */ +function answerWith(...replies: Reply[]): void { + const queue = [...replies]; + safeFetchMock.mockImplementation( + async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => { + calls.push({ url, init, options }); + const next = queue.length > 1 ? queue.shift() : queue[0]; + if (!next) throw new Error('the test queued no answer'); + return next as unknown as Response; + }, + ); +} + +/** A transport-level failure: DNS, TLS, the abort timeout. */ +function failTransport(message: string): void { + safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => { + calls.push({ url, init }); + throw new Error(message); + }); +} + +/** The DawarichError a call rejects with, or a failure saying it did not. */ +async function failure(run: () => Promise): Promise { + try { + await run(); + } catch (err: unknown) { + if (err instanceof DawarichError) return err; + throw err; + } + throw new Error('expected the call to reject with a DawarichError'); +} + +function requestedUrl(index = 0): URL { + return new URL(calls[index].url); +} + +function headerOf(index: number, name: string): string | undefined { + const headers = calls[index].init.headers as Record | undefined; + return headers?.[name]; +} + +let client: DawarichClient; + +beforeEach(() => { + calls = []; + client = new DawarichClient(); + safeFetchMock.mockReset(); + readCappedJsonMock.mockReset(); + // The real reader, minus the stream: parse what arrived, answer undefined when + // it is not JSON — the same verdict the capped reader reaches. + readCappedJsonMock.mockImplementation(async (res: { text(): Promise }) => { + const text = await res.text(); + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } + }); + // Nothing here may reach a real instance: an unstubbed call throws rather + // than silently resolving to undefined. + safeFetchMock.mockImplementation(async () => { + throw new Error('unstubbed safeFetch'); + }); +}); + +describe('DawarichClient — URL building', () => { + it('DAWARICH-CLIENT-001: appends /api/v1 to a bare origin', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe(CREDS); + expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me'); + }); + + it('DAWARICH-CLIENT-002: tolerates a trailing slash', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/' }); + expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me'); + }); + + it('DAWARICH-CLIENT-003: tolerates a pasted /api instead of doubling it', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api' }); + expect(requestedUrl().pathname).toBe('/api/v1/users/me'); + }); + + it('DAWARICH-CLIENT-004: tolerates a pasted /api/v1, with or without the slash', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1' }); + await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1/' }); + expect(requestedUrl(0).href).toBe('https://daw.example.org/api/v1/users/me'); + expect(requestedUrl(1).href).toBe('https://daw.example.org/api/v1/users/me'); + }); + + it('DAWARICH-CLIENT-005: keeps a reverse-proxy sub-path and trims pasted whitespace', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe({ ...CREDS, baseUrl: ' https://home.example.org/dawarich/ ' }); + expect(requestedUrl().href).toBe('https://home.example.org/dawarich/api/v1/users/me'); + }); + + it('DAWARICH-CLIENT-006: keeps a non-default port', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe({ ...CREDS, baseUrl: 'http://192.168.178.9:3000/api' }); + expect(requestedUrl().href).toBe('http://192.168.178.9:3000/api/v1/users/me'); + }); +}); + +describe('DawarichClient — authentication', () => { + it('DAWARICH-CLIENT-010: sends the key as a Bearer token and asks for JSON', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe(CREDS); + expect(headerOf(0, 'Authorization')).toBe(`Bearer ${API_KEY}`); + expect(headerOf(0, 'Accept')).toBe('application/json'); + expect(calls[0].init.method).toBe('GET'); + }); + + it('DAWARICH-CLIENT-011: never puts the key in the query string, on any endpoint', async () => { + answerWith(reply({ body: [] })); + await client.probe(CREDS); + await client.listVisits(CREDS, FROM, TO); + await client.listTracks(CREDS, FROM, TO); + await client.listPoints(CREDS, FROM, TO); + await client.listVisitedCities(CREDS, FROM, TO); + await client.findVisitsNear(CREDS, 54.09, 12.14, 120, 5); + + expect(calls.length).toBeGreaterThanOrEqual(6); + for (const call of calls) { + const url = new URL(call.url); + expect(url.searchParams.get('api_key')).toBeNull(); + expect(call.url).not.toContain(API_KEY); + expect(url.search).not.toMatch(/key|token|auth/i); + } + }); + + it('DAWARICH-CLIENT-012: maps allowInsecureTls onto rejectUnauthorized', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe(CREDS); + await client.probe({ ...CREDS, allowInsecureTls: true }); + expect(calls[0].options).toEqual({ rejectUnauthorized: true }); + expect(calls[1].options).toEqual({ rejectUnauthorized: false }); + }); + + it('DAWARICH-CLIENT-013: bounds the request with an abort signal', async () => { + answerWith(reply({ body: { id: 1 } })); + await client.probe(CREDS); + expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); + }); +}); + +describe('DawarichClient — time formats', () => { + it('DAWARICH-CLIENT-020: isoUtc is UTC ISO-8601 without milliseconds', () => { + expect(isoUtc(new Date('2026-09-01T10:00:30.123Z'))).toBe('2026-09-01T10:00:30Z'); + expect(isoUtc(new Date('2026-09-01T10:00:00.000Z'))).toBe('2026-09-01T10:00:00Z'); + }); + + it('DAWARICH-CLIENT-021: unixSeconds is a bare digit string, floored', () => { + const value = unixSeconds(new Date('2026-09-01T10:00:30.900Z')); + expect(value).toMatch(/^\d+$/); + expect(new Date(Number(value) * 1000).toISOString()).toBe('2026-09-01T10:00:30.000Z'); + }); + + it('DAWARICH-CLIENT-022: visits and tracks get ISO bounds, points and cities unix seconds', async () => { + answerWith(reply({ body: [] })); + await client.listVisits(CREDS, FROM, TO); + await client.listTracks(CREDS, FROM, TO); + await client.listPoints(CREDS, FROM, TO); + await client.listVisitedCities(CREDS, FROM, TO); + + expect(requestedUrl(0).searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z'); + expect(requestedUrl(1).searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z'); + expect(requestedUrl(2).searchParams.get('start_at')).toBe(unixSeconds(FROM)); + expect(requestedUrl(2).searchParams.get('start_at')).toMatch(/^\d+$/); + expect(requestedUrl(3).searchParams.get('end_at')).toBe(unixSeconds(TO)); + }); +}); + +describe('DawarichClient — error classification', () => { + const cases: Array<{ id: string; status: number; code: string }> = [ + { id: 'DAWARICH-CLIENT-030', status: 401, code: 'unauthorized' }, + { id: 'DAWARICH-CLIENT-031', status: 403, code: 'forbidden' }, + { id: 'DAWARICH-CLIENT-032', status: 404, code: 'not_found' }, + { id: 'DAWARICH-CLIENT-033', status: 429, code: 'rate_limited' }, + { id: 'DAWARICH-CLIENT-034', status: 500, code: 'server_error' }, + { id: 'DAWARICH-CLIENT-035', status: 502, code: 'server_error' }, + ]; + + for (const { id, status, code } of cases) { + it(`${id}: HTTP ${status} is "${code}"`, async () => { + answerWith(reply({ status, raw: 'nope' })); + const err = await failure(() => client.probe(CREDS)); + expect(err.code).toBe(code); + expect(err.status).toBe(status); + expect(err.name).toBe('DawarichError'); + // The body of a failed answer is never parsed — it is as likely to be an + // HTML error page as JSON. + expect(readCappedJsonMock).not.toHaveBeenCalled(); + }); + } + + it('DAWARICH-CLIENT-036: a transport failure is "unreachable" and keeps the reason as detail', async () => { + failTransport('getaddrinfo ENOTFOUND daw.example.org'); + const err = await failure(() => client.probe(CREDS)); + expect(err.code).toBe('unreachable'); + expect(err.status).toBeUndefined(); + expect(err.detail).toContain('ENOTFOUND'); + }); + + it('DAWARICH-CLIENT-037: an HTML login page is "invalid_response", not a parse crash', async () => { + answerWith(reply({ raw: 'Sign in' })); + const err = await failure(() => client.probe(CREDS)); + expect(err.code).toBe('invalid_response'); + expect(err.message).toMatch(/not JSON/i); + }); + + it('DAWARICH-CLIENT-038: a body over the size cap is "invalid_response"', async () => { + answerWith(reply({ body: { huge: true } })); + readCappedJsonMock.mockResolvedValue(undefined); + const err = await failure(() => client.probe(CREDS)); + expect(err.code).toBe('invalid_response'); + // The cap is passed in, rather than the reader guessing one. + expect(readCappedJsonMock.mock.calls[0][1]).toBe(8 * 1024 * 1024); + }); + + it('DAWARICH-CLIENT-039: a 2xx answer is not classified as an error', async () => { + answerWith(reply({ status: 204, body: {} })); + await expect(client.probe(CREDS)).resolves.toEqual({ version: null }); + }); +}); + +describe('DawarichClient — probe', () => { + it('DAWARICH-CLIENT-040: asks /users/me and reads X-Dawarich-Version', async () => { + answerWith(reply({ body: { id: 7 }, headers: { 'X-Dawarich-Version': '1.14.4' } })); + const probe = await client.probe(CREDS); + expect(probe).toEqual({ version: '1.14.4' }); + // /health skips authentication upstream, so it would prove nothing. + expect(requestedUrl().pathname).toBe('/api/v1/users/me'); + expect(requestedUrl().pathname).not.toContain('health'); + expect(requestedUrl().search).toBe(''); + }); + + it('DAWARICH-CLIENT-041: an instance too old to send the banner probes as version null', async () => { + answerWith(reply({ body: { id: 7 } })); + await expect(client.probe(CREDS)).resolves.toEqual({ version: null }); + }); +}); + +describe('DawarichClient — listVisits', () => { + function visit(id: number): DawarichVisitRaw { + return { + id, + area_id: null, + started_at: '2026-09-02T08:00:00Z', + ended_at: '2026-09-02T09:30:00Z', + duration: 5400, + name: `Stop ${id}`, + status: 'confirmed', + confidence: 0.9, + confidence_band: 'high', + place: { latitude: '54.0879', longitude: '12.1408', id: id * 10 }, + }; + } + + it('DAWARICH-CLIENT-050: sends the window, the page and the upstream page size', async () => { + answerWith(reply({ body: [visit(1)] })); + const out = await client.listVisits(CREDS, FROM, TO); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v1/visits'); + expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z'); + expect(url.searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z'); + expect(url.searchParams.get('page')).toBe('1'); + expect(url.searchParams.get('per_page')).toBe(String(VISITS_PAGE_SIZE)); + expect(out.visits).toHaveLength(1); + expect(out.truncated).toBe(false); + }); + + it('DAWARICH-CLIENT-051: walks every page X-Total-Pages announces and concatenates them', async () => { + answerWith( + reply({ body: [visit(1)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '1.14.4' } }), + reply({ body: [visit(2)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '9.9.9' } }), + reply({ body: [visit(3)], headers: { 'X-Total-Pages': '3' } }), + ); + const out = await client.listVisits(CREDS, FROM, TO); + + expect(calls).toHaveLength(3); + expect(out.visits.map(v => v.id)).toEqual([1, 2, 3]); + expect(out.truncated).toBe(false); + // The first page's banner is the answer; a later page cannot overwrite it. + expect(out.version).toBe('1.14.4'); + expect(calls.map(c => new URL(c.url).searchParams.get('page'))).toEqual(['1', '2', '3']); + }); + + it('DAWARICH-CLIENT-052: an absent X-Total-Pages means one page', async () => { + answerWith(reply({ body: [visit(1)] })); + const out = await client.listVisits(CREDS, FROM, TO); + expect(calls).toHaveLength(1); + expect(out.visits).toHaveLength(1); + expect(out.version).toBeNull(); + }); + + it('DAWARICH-CLIENT-053: stops at the page ceiling and says the result is truncated', async () => { + answerWith(reply({ body: [visit(1)], headers: { 'X-Total-Pages': '25' } })); + const out = await client.listVisits(CREDS, FROM, TO); + + expect(calls).toHaveLength(20); + expect(out.visits).toHaveLength(20); + expect(out.truncated).toBe(true); + expect(new URL(calls[19].url).searchParams.get('page')).toBe('20'); + }); + + it('DAWARICH-CLIENT-054: a payload that is not a list is "invalid_response"', async () => { + answerWith(reply({ body: { visits: [] } })); + const err = await failure(() => client.listVisits(CREDS, FROM, TO)); + expect(err.code).toBe('invalid_response'); + expect(err.message).toMatch(/not a list/i); + }); +}); + +describe('DawarichClient — listTracks', () => { + const feature = (id: number) => ({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: [[12.1, 54.0], [12.2, 54.1]] }, + properties: { id, start_at: '2026-09-02T08:00:00Z', end_at: '2026-09-02T09:00:00Z' }, + }); + + it('DAWARICH-CLIENT-060: reads the GeoJSON features and asks for the smaller page size', async () => { + answerWith(reply({ body: { type: 'FeatureCollection', features: [feature(1), feature(2)] } })); + const out = await client.listTracks(CREDS, FROM, TO); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v1/tracks'); + expect(url.searchParams.get('per_page')).toBe(String(TRACKS_PAGE_SIZE)); + expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z'); + expect(out.features.map(f => f.properties?.id)).toEqual([1, 2]); + expect(out.truncated).toBe(false); + }); + + it('DAWARICH-CLIENT-061: an instance with no tracks yet answers empty rather than throwing', async () => { + answerWith(reply({ body: { type: 'FeatureCollection' } })); + await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false }); + + calls = []; + answerWith(reply({ body: { features: 'nope' } })); + await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false }); + }); + + it('DAWARICH-CLIENT-062: paginates over X-Total-Pages and truncates at the ceiling', async () => { + answerWith( + reply({ body: { features: [feature(1)] }, headers: { 'X-Total-Pages': '2' } }), + reply({ body: { features: [feature(2)] }, headers: { 'X-Total-Pages': '2' } }), + ); + const paged = await client.listTracks(CREDS, FROM, TO); + expect(calls).toHaveLength(2); + expect(paged.features).toHaveLength(2); + expect(paged.truncated).toBe(false); + + calls = []; + answerWith(reply({ body: { features: [feature(9)] }, headers: { 'X-Total-Pages': '99' } })); + const capped = await client.listTracks(CREDS, FROM, TO); + expect(calls).toHaveLength(20); + expect(capped.truncated).toBe(true); + }); +}); + +describe('DawarichClient — listPoints', () => { + const point = (id: number) => ({ id, latitude: '54.0879', longitude: '12.1408', timestamp: 1788000000 + id }); + + it('DAWARICH-CLIENT-070: asks for slim points in ascending order over a unix-second window', async () => { + answerWith(reply({ body: [point(1)] })); + const out = await client.listPoints(CREDS, FROM, TO); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v1/points'); + expect(url.searchParams.get('slim')).toBe('true'); + expect(url.searchParams.get('order')).toBe('asc'); + expect(url.searchParams.get('per_page')).toBe(String(POINTS_PAGE_SIZE)); + expect(url.searchParams.get('start_at')).toBe(unixSeconds(FROM)); + expect(url.searchParams.get('end_at')).toBe(unixSeconds(TO)); + expect(out).toEqual({ points: [point(1)], truncated: false }); + }); + + it('DAWARICH-CLIENT-071: paginates over X-Total-Pages and truncates at the ceiling', async () => { + answerWith( + reply({ body: [point(1)], headers: { 'X-Total-Pages': '2' } }), + reply({ body: [point(2)], headers: { 'X-Total-Pages': '2' } }), + ); + const paged = await client.listPoints(CREDS, FROM, TO); + expect(calls).toHaveLength(2); + expect(paged.points.map(p => p.id)).toEqual([1, 2]); + expect(paged.truncated).toBe(false); + + calls = []; + answerWith(reply({ body: [point(3)], headers: { 'X-Total-Pages': '40' } })); + const capped = await client.listPoints(CREDS, FROM, TO); + expect(calls).toHaveLength(20); + expect(capped.points).toHaveLength(20); + expect(capped.truncated).toBe(true); + }); + + it('DAWARICH-CLIENT-072: a payload that is not a list is "invalid_response"', async () => { + answerWith(reply({ body: { points: [] } })); + const err = await failure(() => client.listPoints(CREDS, FROM, TO)); + expect(err.code).toBe('invalid_response'); + expect(err.message).toMatch(/not a list/i); + }); +}); + +describe('DawarichClient — listVisitedCities', () => { + it('DAWARICH-CLIENT-080: unwraps the data envelope over a unix-second window', async () => { + const countries = [{ country: 'Germany', cities: [{ city: 'Rostock', points: 42, timestamp: 1788000000 }] }]; + answerWith(reply({ body: { data: countries } })); + const out = await client.listVisitedCities(CREDS, FROM, TO); + + expect(requestedUrl().pathname).toBe('/api/v1/countries/visited_cities'); + expect(requestedUrl().searchParams.get('start_at')).toBe(unixSeconds(FROM)); + expect(out).toEqual(countries); + }); + + it('DAWARICH-CLIENT-081: an envelope without a list answers empty', async () => { + answerWith(reply({ body: {} })); + await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]); + + calls = []; + answerWith(reply({ body: { data: { Germany: [] } } })); + await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]); + }); +}); + +describe('DawarichClient — findVisitsNear', () => { + it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => { + const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }]; + answerWith(reply({ body: { locations: [{ visits }] } })); + const out = await client.findVisitsNear(CREDS, 54.0879, 12.1408, 119.6, 5); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v1/locations'); + expect(url.searchParams.get('lat')).toBe('54.0879'); + expect(url.searchParams.get('lon')).toBe('12.1408'); + expect(url.searchParams.get('radius_override')).toBe('120'); + expect(url.searchParams.get('limit')).toBe('5'); + expect(out).toEqual(visits); + }); + + it('DAWARICH-CLIENT-091: a coordinate the recordings never saw answers empty', async () => { + answerWith(reply({ body: { locations: [] } })); + await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]); + + calls = []; + answerWith(reply({ body: {} })); + await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]); + + calls = []; + answerWith(reply({ body: { locations: [{}] } })); + await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts new file mode 100644 index 000000000..2fdecbf73 --- /dev/null +++ b/server/tests/unit/nest/dawarich.controller.test.ts @@ -0,0 +1,584 @@ +/** + * DawarichController unit tests — the controller built with `new`, every + * collaborator a plain mock. No Nest container, no DI, no network: the + * DawarichClient is never constructed here, because the only things under test + * are the ones the controller itself owns. + * + * Those are three: + * + * - **the parsers** (`parseId`, `parseOptionalId`, `parseState`, `parseDate`, + * `parseWindow`), the reason a bad path segment never reaches a query. They + * are module-private on purpose, so they are pinned through the handlers + * that use them rather than exported for a test's convenience; + * - **`toHttp`**, the one place a domain error becomes a status code — an + * `AcceptError` keeps the status the domain chose, a `DawarichError` becomes + * 502 except for the two codes that mean "your key is wrong", which are 400 + * because they are the caller's to fix and not the upstream's fault; + * - **the delegation**, `X-Socket-Id` included, whose loss would make an + * accept echo straight back into the tab that performed it. + */ +import { describe, it, expect, vi } from 'vitest'; +import { HttpException } from '@nestjs/common'; +import type { Request } from 'express'; +import type { DawarichErrorCode } from '@trek/shared'; +import { DawarichController } from '../../../src/nest/integrations/dawarich.controller'; +import { AcceptError } from '../../../src/nest/integrations/dawarich-suggestions.service'; +import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; +import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard'; +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import { ADDON_IDS } from '../../../src/addons'; +import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service'; +import type { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service'; +import type { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service'; +import type { User } from '../../../src/types'; + +const user = { id: 7 } as User; + +interface Mocks { + dawarich?: Partial; + sync?: Partial; + suggestions?: Partial; + tracks?: Partial; +} + +function makeController(m: Mocks = {}) { + return new DawarichController( + (m.dawarich ?? {}) as DawarichService, + (m.sync ?? {}) as DawarichSyncService, + (m.suggestions ?? {}) as DawarichSuggestionsService, + (m.tracks ?? {}) as DawarichTracksService, + ); +} + +/** A request stub carrying only what the controller reads off it. */ +function makeReq(headers: Record = {}, ip = '10.1.2.3'): Request { + return { headers, ip, socket: { remoteAddress: ip } } as unknown as Request; +} + +/** A valid settings body — the two defaulted fields are required in the DTO's output type. */ +function settings(over: Partial<{ url: string; apiKey: string; allowInsecureTls: boolean; syncEnabled: boolean }> = {}) { + return { + url: 'https://dawarich.example', + allowInsecureTls: false, + syncEnabled: true, + ...over, + }; +} + +async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> { + try { + await fn(); + } catch (err) { + expect(err).toBeInstanceOf(HttpException); + const e = err as HttpException; + return { status: e.getStatus(), body: e.getResponse() }; + } + throw new Error('expected the handler to throw'); +} + +// --------------------------------------------------------------------------- +// Route wiring +// --------------------------------------------------------------------------- + +describe('DawarichController wiring', () => { + it('DAWARICH-CTRL-001: answers under /api/integrations/dawarich', () => { + expect(Reflect.getMetadata('path', DawarichController)).toBe('api/integrations/dawarich'); + }); + + it('DAWARICH-CTRL-002: AddonGuard runs BEFORE JwtAuthGuard, so a disabled addon 404s an anonymous caller instead of confirming the route with a 401', () => { + const guards = Reflect.getMetadata('__guards__', DawarichController) as unknown[] | undefined; + // Array shape asserted first: expect(undefined).toContain(X) passes in this + // vitest version, so the plain form would stop guarding the moment the + // decorator disappeared. + expect(Array.isArray(guards)).toBe(true); + expect(guards).toEqual([AddonGuard, JwtAuthGuard]); + }); + + it('DAWARICH-CTRL-003: gates on the dawarich addon, with the label that leads the 404 body', () => { + expect(Reflect.getMetadata(REQUIRE_ADDON, DawarichController)).toEqual({ + addonId: ADDON_IDS.DAWARICH, + label: 'Dawarich', + }); + }); +}); + +// --------------------------------------------------------------------------- +// Connection routes +// --------------------------------------------------------------------------- + +describe('DawarichController connection routes', () => { + it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', () => { + const connection = { url: 'https://d.example', connected: true }; + const getConnection = vi.fn().mockReturnValue(connection); + expect(makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection); + expect(getConnection).toHaveBeenCalledWith(7); + }); + + it('DAWARICH-CTRL-011: PUT settings forwards url, key, tls flag, syncEnabled and the client ip', async () => { + const saveSettings = vi.fn().mockResolvedValue({ success: true }); + const forget = vi.fn(); + const c = makeController({ dawarich: { saveSettings }, tracks: { forget } }); + + const out = await c.putSettings( + user, + settings({ apiKey: 'k', allowInsecureTls: true, syncEnabled: false }), + makeReq({}, '203.0.113.9'), + ); + + expect(out).toEqual({ success: true }); + expect(saveSettings).toHaveBeenCalledWith(7, 'https://dawarich.example', 'k', true, false, '203.0.113.9'); + }); + + it('DAWARICH-CTRL-012: only an explicit false turns the background poll off', async () => { + const saveSettings = vi.fn().mockResolvedValue({ success: true }); + const c = makeController({ dawarich: { saveSettings }, tracks: { forget: vi.fn() } }); + + await c.putSettings(user, settings({ syncEnabled: true }), makeReq()); + expect(saveSettings.mock.calls[0][4]).toBe(true); + }); + + it('DAWARICH-CTRL-013: a rejected URL is a 400 carrying the service reason, and the cached track is left alone', async () => { + const saveSettings = vi.fn().mockResolvedValue({ success: false, error: 'Invalid URL' }); + const forget = vi.fn(); + const c = makeController({ dawarich: { saveSettings }, tracks: { forget } }); + + expect(await thrown(() => c.putSettings(user, settings({ url: 'nope' }), makeReq()))) + .toEqual({ status: 400, body: { error: 'Invalid URL' } }); + expect(forget).not.toHaveBeenCalled(); + }); + + it("DAWARICH-CTRL-014: a saved connection drops the cached track, so the old instance's route is not drawn under the new one's name", async () => { + const forget = vi.fn(); + const c = makeController({ + dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true }) }, + tracks: { forget }, + }); + await c.putSettings(user, settings(), makeReq()); + expect(forget).toHaveBeenCalledWith(7); + }); + + it('DAWARICH-CTRL-015: a private-IP warning reaches the caller alongside the success', async () => { + const c = makeController({ + dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'resolves to a private IP' }) }, + tracks: { forget: vi.fn() }, + }); + expect(await c.putSettings(user, settings({ url: 'http://192.168.0.5:3000' }), makeReq())) + .toEqual({ success: true, warning: 'resolves to a private IP' }); + }); + + it('DAWARICH-CTRL-016: DELETE settings disconnects, forgets the track and answers 200 { success: true }', () => { + const disconnect = vi.fn(); + const forget = vi.fn(); + const c = makeController({ dawarich: { disconnect }, tracks: { forget } }); + + expect(c.disconnect(user, makeReq({}, '198.51.100.4'))).toEqual({ success: true }); + expect(disconnect).toHaveBeenCalledWith(7, '198.51.100.4'); + expect(forget).toHaveBeenCalledWith(7); + }); + + it('DAWARICH-CTRL-017: POST test answers the probe verbatim — an unusable connection is a 200 the form can render, not an exception', async () => { + const status = { connected: false, error: 'unauthorized' }; + const testConnection = vi.fn().mockResolvedValue(status); + const c = makeController({ dawarich: { testConnection } }); + + await expect(c.test(user, settings({ apiKey: 'wrong' }))).resolves.toBe(status); + expect(testConnection).toHaveBeenCalledWith(7, 'https://dawarich.example', 'wrong', false); + }); + + it('DAWARICH-CTRL-018: POST sync pulls for the caller and returns the counts', async () => { + const result = { state: 'ok', created: 2, updated: 1, missing: 0 }; + const syncUser = vi.fn().mockResolvedValue(result); + await expect(makeController({ sync: { syncUser } }).syncNow(user)).resolves.toBe(result); + expect(syncUser).toHaveBeenCalledWith(7); + }); +}); + +// --------------------------------------------------------------------------- +// parseOptionalId / parseState — the suggestions list filters +// --------------------------------------------------------------------------- + +describe('DawarichController GET /suggestions filters', () => { + it('DAWARICH-CTRL-020: no filters means both are undefined', () => { + const list = vi.fn().mockReturnValue({ suggestions: [] }); + makeController({ suggestions: { list } }).listSuggestions(user); + expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); + }); + + it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', () => { + const list = vi.fn().mockReturnValue({ suggestions: [] }); + makeController({ suggestions: { list } }).listSuggestions(user, '', ''); + expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); + }); + + it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', () => { + const list = vi.fn().mockReturnValue({ suggestions: [] }); + makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new'); + expect(list).toHaveBeenCalledWith(7, { tripId: 42, state: 'new' }); + }); + + it('DAWARICH-CTRL-023: 400 on a tripId that merely starts with digits, and the field is named', () => { + const list = vi.fn(); + return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, '12abc')).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'Invalid tripId' } }); + expect(list).not.toHaveBeenCalled(); + }); + }); + + it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', (state) => { + const list = vi.fn().mockReturnValue({ suggestions: [] }); + makeController({ suggestions: { list } }).listSuggestions(user, undefined, state); + expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state }); + }); + + it.each(['NEW', 'pending', 'all', "new' OR 1=1"])( + 'DAWARICH-CTRL-025: 400 on the unknown state %j rather than a silently unfiltered list', + (state) => { + const list = vi.fn(); + return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, undefined, state)).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'state must be one of: new, accepted, dismissed' } }); + expect(list).not.toHaveBeenCalled(); + }); + }, + ); +}); + +// --------------------------------------------------------------------------- +// parseId — every route that takes an id in the path +// --------------------------------------------------------------------------- + +describe('DawarichController parseId', () => { + const bad = ['12abc', 'abc', '', ' 12', '12 ', '1.5', '-3', '0', '+7', '1e3', '0x10', '9007199254740993']; + + it.each(bad)('DAWARICH-CTRL-030: POST accept refuses the id %j with 400 and never reaches the service', (raw) => { + const accept = vi.fn(); + return thrown(() => makeController({ suggestions: { accept } }) + .acceptSuggestion(user, raw, { target: 'place' }, makeReq())).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }); + expect(accept).not.toHaveBeenCalled(); + }); + }); + + it('DAWARICH-CTRL-031: "12abc" is never read as 12 — this is exactly what parseInt would have let through', () => { + const setState = vi.fn(); + return thrown(() => makeController({ suggestions: { setState } }) + .setSuggestionState(user, '12abc', { state: 'dismissed' })).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }); + expect(setState).not.toHaveBeenCalled(); + }); + }); + + it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', () => { + const setState = vi.fn().mockReturnValue({ id: 12 }); + const c = makeController({ suggestions: { setState } }); + expect(c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 }); + expect(setState).toHaveBeenCalledWith(7, 12, 'new'); + }); + + it('DAWARICH-CTRL-033: DELETE bucket-list/:itemId/visit refuses a non-numeric item id with 400', () => { + const clearBucketVisit = vi.fn(); + return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '12abc')).then((r) => { + expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } }); + expect(clearBucketVisit).not.toHaveBeenCalled(); + }); + }); + + it('DAWARICH-CTRL-034: GET trips/:tripId/track refuses a non-numeric trip id with 400, not with the guard\'s 502', async () => { + const forTrip = vi.fn(); + expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '12abc'))) + .toEqual({ status: 400, body: { error: 'Invalid id' } }); + expect(forTrip).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Suggestion state, accept, bucket list, atlas +// --------------------------------------------------------------------------- + +describe('DawarichController suggestion routes', () => { + it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', () => { + const updated = { id: 3, state: 'dismissed' }; + const setState = vi.fn().mockReturnValue(updated); + expect(makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated); + expect(setState).toHaveBeenCalledWith(7, 3, 'dismissed'); + }); + + it("DAWARICH-CTRL-041: PUT state on somebody else's suggestion is the same 404 a missing one gets — no enumeration", () => { + const setState = vi.fn().mockReturnValue(null); + return thrown(() => makeController({ suggestions: { setState } }).setSuggestionState(user, '999', { state: 'new' })).then((r) => + expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found' } })); + }); + + it('DAWARICH-CTRL-042: POST accept forwards X-Socket-Id, so the broadcast does not echo into the originating tab', () => { + const accept = vi.fn().mockReturnValue({ createdPlaceId: 5 }); + const body = { target: 'place' as const, tripId: 2 }; + makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', body, makeReq({ 'x-socket-id': 'sock-abc' })); + expect(accept).toHaveBeenCalledWith(7, 9, body, 'sock-abc'); + }); + + it('DAWARICH-CTRL-043: no X-Socket-Id means undefined, not an empty string a broadcaster would compare against', () => { + const accept = vi.fn().mockReturnValue({}); + makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq()); + expect(accept.mock.calls[0][3]).toBeUndefined(); + }); + + it('DAWARICH-CTRL-044: a blank X-Socket-Id counts as absent', () => { + const accept = vi.fn().mockReturnValue({}); + makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq({ 'x-socket-id': '' })); + expect(accept.mock.calls[0][3]).toBeUndefined(); + }); + + it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', () => { + const confirmBucketVisits = vi.fn().mockReturnValue(3); + expect(makeController({ suggestions: { confirmBucketVisits } }) + .confirmBucketVisits(user, { itemIds: [1, 2, 3], visitedAt: '2026-05-01' })).toEqual({ updated: 3 }); + expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01'); + }); + + it('DAWARICH-CTRL-046: DELETE bucket-list/:itemId/visit is a 404 when nothing was cleared', () => { + const clearBucketVisit = vi.fn().mockReturnValue(false); + return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).then((r) => + expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } })); + }); + + it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', () => { + const acceptAtlasCountries = vi.fn().mockReturnValue(2); + expect(makeController({ suggestions: { acceptAtlasCountries } }) + .acceptAtlasCountries(user, { countryCodes: ['DE', 'FR'] })).toEqual({ marked: 2 }); + expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR']); + }); + + it('DAWARICH-CTRL-048: POST bucket-list/scan returns the scan untouched', async () => { + const scan = { items: [], connected: true }; + const scanBucketList = vi.fn().mockResolvedValue(scan); + await expect(makeController({ suggestions: { scanBucketList } }).scanBucketList(user)).resolves.toBe(scan); + expect(scanBucketList).toHaveBeenCalledWith(7); + }); +}); + +// --------------------------------------------------------------------------- +// toHttp — the one place a domain error becomes a status +// --------------------------------------------------------------------------- + +describe('DawarichController error shaping', () => { + it('DAWARICH-CTRL-050: an AcceptError keeps the 404 the domain chose', () => { + const accept = vi.fn(() => { throw new AcceptError('not_found', 'Suggestion not found', 404); }); + return thrown(() => makeController({ suggestions: { accept } }) + .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) => + expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found', code: 'not_found' } })); + }); + + it('DAWARICH-CTRL-051: an AcceptError keeps a 409 too — the status travels with the error, it is not re-derived here', () => { + const accept = vi.fn(() => { throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409); }); + return thrown(() => makeController({ suggestions: { accept } }) + .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) => + expect(r).toEqual({ status: 409, body: { error: 'Suggestion has already been accepted', code: 'already_accepted' } })); + }); + + it('DAWARICH-CTRL-052: an AcceptError keeps a 400 (a day that is not on the trip)', () => { + const accept = vi.fn(() => { throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400); }); + return thrown(() => makeController({ suggestions: { accept } }) + .acceptSuggestion(user, '9', { target: 'place', dayId: 4 }, makeReq())).then((r) => + expect(r).toEqual({ status: 400, body: { error: 'Day does not belong to this trip', code: 'day_not_on_trip' } })); + }); + + const upstreamCodes: DawarichErrorCode[] = [ + 'unreachable', + 'not_found', + 'rate_limited', + 'server_error', + 'invalid_response', + 'too_large', + 'not_connected', + ]; + + it.each(upstreamCodes)( + 'DAWARICH-CTRL-053: a DawarichError with code %s becomes a 502 — the failure is a server TREK called, not TREK itself', + async (code) => { + const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'upstream said no')); + expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user))) + .toEqual({ status: 502, body: { error: 'upstream said no', code } }); + }, + ); + + const callerCodes: DawarichErrorCode[] = ['unauthorized', 'forbidden']; + + it.each(callerCodes)( + "DAWARICH-CTRL-054: a DawarichError with code %s becomes a 400 — a rejected key is the caller's to fix, not a gateway fault", + async (code) => { + const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'key rejected', 401)); + expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user))) + .toEqual({ status: 400, body: { error: 'key rejected', code } }); + }, + ); + + it('DAWARICH-CTRL-055: the code reaches the client for i18n, and `detail` rides along only when there is one', async () => { + const scanBucketList = vi.fn().mockRejectedValue( + new DawarichError('invalid_response', 'Unexpected response', 200, 'login'), + ); + expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user))) + .toEqual({ status: 502, body: { error: 'Unexpected response', code: 'invalid_response', detail: 'login' } }); + }); + + it('DAWARICH-CTRL-056: an HttpException raised inside the guarded call passes through untouched', async () => { + const forTrip = vi.fn().mockRejectedValue(new HttpException({ error: 'Trip not found' }, 404)); + expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '3'))) + .toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + + it('DAWARICH-CTRL-057: anything else becomes a generic 502 rather than leaking the message', async () => { + const atlasSuggestions = vi.fn().mockRejectedValue(new Error('ECONNRESET on /api/v1/visits?api_key=secret')); + expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } }) + .atlasSuggestions(user, '2026-01-01T00:00:00Z', '2026-01-05T00:00:00Z'))) + .toEqual({ status: 502, body: { error: 'Dawarich request failed', code: 'server_error' } }); + }); + + it('DAWARICH-CTRL-058: the synchronous guard shapes a throw from accept exactly as the async one does', () => { + const accept = vi.fn(() => { throw new DawarichError('unauthorized', 'key rejected'); }); + return thrown(() => makeController({ suggestions: { accept } }) + .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) => + expect(r).toEqual({ status: 400, body: { error: 'key rejected', code: 'unauthorized' } })); + }); +}); + +// --------------------------------------------------------------------------- +// parseWindow — the explicit instant range +// --------------------------------------------------------------------------- + +describe('DawarichController parseWindow', () => { + const halfOpen: Array<[string | undefined, string | undefined]> = [ + [undefined, undefined], + ['2026-01-01T00:00:00Z', undefined], + [undefined, '2026-01-05T00:00:00Z'], + ['', '2026-01-05T00:00:00Z'], + ['2026-01-01T00:00:00Z', ''], + ['nonsense', '2026-01-05T00:00:00Z'], + ['2026-01-01T00:00:00Z', 'nonsense'], + ]; + + it.each(halfOpen)( + 'DAWARICH-CTRL-060: GET atlas/suggestions refuses the window (%j, %j) with 400 — one open end is "everything since the epoch"', + async (from, to) => { + const atlasSuggestions = vi.fn(); + expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from, to))) + .toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } }); + expect(atlasSuggestions).not.toHaveBeenCalled(); + }, + ); + + it.each(halfOpen)('DAWARICH-CTRL-061: GET track refuses the window (%j, %j) with 400', async (from, to) => { + const forWindow = vi.fn(); + expect(await thrown(() => makeController({ tracks: { forWindow } }).windowTrack(user, from, to))) + .toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } }); + expect(forWindow).not.toHaveBeenCalled(); + }); + + it('DAWARICH-CTRL-062: an inverted window is a 400', async () => { + expect(await thrown(() => makeController({ suggestions: { atlasSuggestions: vi.fn() } }) + .atlasSuggestions(user, '2026-02-01T00:00:00Z', '2026-01-01T00:00:00Z'))) + .toEqual({ status: 400, body: { error: 'to must be after from' } }); + }); + + it('DAWARICH-CTRL-063: a zero-length window is a 400 — "after from", not "at or after"', async () => { + expect(await thrown(() => makeController({ tracks: { forWindow: vi.fn() } }) + .windowTrack(user, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'))) + .toEqual({ status: 400, body: { error: 'to must be after from' } }); + }); + + it('DAWARICH-CTRL-064: exactly 400 days is still allowed', async () => { + const atlasSuggestions = vi.fn().mockResolvedValue({ countries: [] }); + const from = new Date('2026-01-01T00:00:00Z'); + const to = new Date(from.getTime() + 400 * 86_400_000); + + await makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from.toISOString(), to.toISOString()); + + expect(atlasSuggestions).toHaveBeenCalledWith(7, from, to); + }); + + it('DAWARICH-CTRL-065: one millisecond past 400 days is a 400, so nobody can ask an instance to time out', async () => { + const atlasSuggestions = vi.fn(); + const from = new Date('2026-01-01T00:00:00Z'); + const to = new Date(from.getTime() + 400 * 86_400_000 + 1); + + expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } }) + .atlasSuggestions(user, from.toISOString(), to.toISOString()))) + .toEqual({ status: 400, body: { error: 'Window must be 400 days or less' } }); + expect(atlasSuggestions).not.toHaveBeenCalled(); + }); + + it('DAWARICH-CTRL-066: GET track normalises both ends to ISO before handing them on', async () => { + const forWindow = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forWindow } }).windowTrack(user, '2026-01-01T00:00:00+02:00', '2026-01-02T00:00:00+02:00'); + + // The trailing 0 is the caller's UTC offset: absent means UTC, which is + // what this endpoint grouped by before it asked. + expect(forWindow).toHaveBeenCalledWith(7, '2025-12-31T22:00:00.000Z', '2026-01-01T22:00:00.000Z', 0); + }); +}); + +// --------------------------------------------------------------------------- +// parseDate — the trip-track narrowing +// --------------------------------------------------------------------------- + +describe('DawarichController GET /trips/:tripId/track', () => { + it('DAWARICH-CTRL-070: no narrowing means the service decides the window from the trip', async () => { + const forTrip = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forTrip } }).tripTrack(user, '5'); + expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0); + }); + + it('DAWARICH-CTRL-071: an empty from/to is "no narrowing", not an empty date', async () => { + const forTrip = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '', ''); + expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0); + }); + + it('DAWARICH-CTRL-072: YYYY-MM-DD passes through', async () => { + const forTrip = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '2026-05-01', '2026-05-03'); + expect(forTrip).toHaveBeenCalledWith(7, 5, '2026-05-01', '2026-05-03', 0); + }); + + it('DAWARICH-CTRL-074: the offset from the caller decides which day a point belongs to', async () => { + const forTrip = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, '120'); + expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 120); + }); + + it.each([ + ['9999', 840], + ['-9999', -720], + ['not-a-number', 0], + ['', 0], + ['-210.9', -210], + ])('DAWARICH-CTRL-075: the offset %j is bounded to the real world as %i', async (raw, expected) => { + // A junk offset must not be able to shift a day by a week; the bounds are + // the actual range of world offsets, -12h to +14h. + const forTrip = vi.fn().mockResolvedValue({ days: [] }); + await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, raw); + expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, expected); + }); + + it.each(['2026-5-1', '01.05.2026', '2026-05-01T00:00:00Z', 'yesterday'])( + 'DAWARICH-CTRL-073: 400 on the non-ISO date %j', + async (raw) => { + const forTrip = vi.fn(); + expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5', raw))) + .toEqual({ status: 400, body: { error: 'Dates must be ISO, YYYY-MM-DD' } }); + expect(forTrip).not.toHaveBeenCalled(); + }, + ); + + it('DAWARICH-CTRL-074: a trip the caller cannot read is a 404 — the service says null, the controller says not found', async () => { + const forTrip = vi.fn().mockResolvedValue(null); + expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5'))) + .toEqual({ status: 404, body: { error: 'Trip not found' } }); + }); + + it('DAWARICH-CTRL-075: a track comes back untouched', async () => { + const track = { days: [{ date: '2026-05-01', segments: [] }], truncated: false }; + const forTrip = vi.fn().mockResolvedValue(track); + await expect(makeController({ tracks: { forTrip } }).tripTrack(user, '5')).resolves.toBe(track); + }); +}); diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts new file mode 100644 index 000000000..d6c0a1d13 --- /dev/null +++ b/server/tests/unit/nest/dawarich.helpers.test.ts @@ -0,0 +1,858 @@ +/** + * The pure half of the Dawarich integration — DAWARICH-TIME-001 through + * DAWARICH-COUNT-003. Everything under test is a free function over plain + * fixtures: no DB, no container, and no client. `dawarich.client` appears here + * only as a type-only import (erased at compile time), so nothing in this file + * can reach the network even by accident — there is no DawarichClient instance + * to mock because the helpers never construct one. + * + * Every timestamp is written with an explicit offset (`Z` or `+HH:MM`) so the + * cases mean the same thing on a CI runner in UTC and on a laptop in Berlin. + */ +import { describe, it, expect } from 'vitest'; + +import { + bucketPointsByDay, + bucketTracksByDay, + capPoints, + countPoints, + distanceMeters, + geoJsonToLatLng, + localDateOf, + minutesBetween, + normalizeVisit, + offsetMinutesOf, + simplify, + syncWindow, + toNumber, + visitHash, +} from '../../../src/nest/integrations/dawarich.helpers'; +import type { + DawarichSlimPoint, + DawarichTrackFeature, + DawarichVisitRaw, +} from '../../../src/nest/integrations/dawarich.client'; +import type { DawarichTrackDay } from '@trek/shared'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const BASE_VISIT: DawarichVisitRaw = { + id: 42, + area_id: null, + started_at: '2024-06-15T18:30:00+02:00', + ended_at: '2024-06-15T20:00:00+02:00', + duration: 5400, + name: ' Cafe Kranzler ', + status: 'confirmed', + confidence: '0.87', + confidence_band: 'high', + place: { latitude: '52.5', longitude: '13.4', id: 7, country_code: 'de' }, +}; + +const visit = (over: Partial = {}): DawarichVisitRaw => ({ ...BASE_VISIT, ...over }); + +/** A fixture whose shape is deliberately wrong — the payload is a stranger's JSON. */ +const malformedVisit = (over: Record): DawarichVisitRaw => + ({ ...BASE_VISIT, ...over }) as unknown as DawarichVisitRaw; + +const point = (iso: string, lat: number | string, lng: number | string): DawarichSlimPoint => ({ + latitude: lat, + longitude: lng, + timestamp: Date.parse(iso) / 1000, +}); + +const track = ( + startAt: string | undefined, + endAt: string | undefined, + coordinates: unknown, + props: { distance?: number | null; dominant_mode?: string | null } = {}, +): DawarichTrackFeature => ({ + geometry: { coordinates }, + properties: { start_at: startAt, end_at: endAt, distance: null, dominant_mode: null, ...props }, +}); + +// ── localDateOf / offsetMinutesOf ──────────────────────────────────────────── + +describe('localDateOf / offsetMinutesOf', () => { + it('DAWARICH-TIME-001: reads the local date off the string rather than converting it', () => { + // 23:30 in Tokyo is still the 15th there and the 15th in Dawarich, even + // though the same instant is the 15th 14:30 UTC. + expect(localDateOf('2024-06-15T23:30:00+09:00')).toBe('2024-06-15'); + }); + + it('DAWARICH-TIME-002: takes a UTC instant as given — there is no local meaning to recover', () => { + expect(localDateOf('2024-06-15T23:30:00Z')).toBe('2024-06-15'); + }); + + it('DAWARICH-TIME-003: reads a positive offset as minutes', () => { + expect(offsetMinutesOf('2024-06-15T10:00:00+02:00')).toBe(120); + }); + + it('DAWARICH-TIME-004: reads a negative half-hour offset as minutes', () => { + expect(offsetMinutesOf('2024-06-15T10:00:00-05:30')).toBe(-330); + }); + + it('DAWARICH-TIME-005: accepts the colon-less form', () => { + expect(offsetMinutesOf('2024-06-15T10:00:00+0200')).toBe(120); + }); + + it('DAWARICH-TIME-006: Z, a bare date and junk are all zero', () => { + expect(offsetMinutesOf('2024-06-15T10:00:00Z')).toBe(0); + // A bare date ends in `-15`, which must not be mistaken for an offset. + expect(offsetMinutesOf('2024-06-15')).toBe(0); + expect(offsetMinutesOf('not a timestamp')).toBe(0); + }); +}); + +// ── minutesBetween ─────────────────────────────────────────────────────────── + +describe('minutesBetween', () => { + it('DAWARICH-TIME-007: whole minutes across a span', () => { + expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T11:30:00Z')).toBe(90); + }); + + it('DAWARICH-TIME-008: rounds to the nearest minute in both directions', () => { + expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:29Z')).toBe(0); + expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:31Z')).toBe(1); + }); + + it('DAWARICH-TIME-009: compares instants, not wall clocks', () => { + // 10:00+02:00 is 08:00Z; 11:00+01:00 is 10:00Z — two hours apart. + expect(minutesBetween('2024-06-15T10:00:00+02:00', '2024-06-15T11:00:00+01:00')).toBe(120); + }); + + it('DAWARICH-TIME-010: a backwards or zero-length span is zero, never negative', () => { + expect(minutesBetween('2024-06-15T11:00:00Z', '2024-06-15T10:00:00Z')).toBe(0); + expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:00Z')).toBe(0); + }); + + it('DAWARICH-TIME-011: an unparsable timestamp is zero rather than NaN', () => { + expect(minutesBetween('2024-06-15T10:00:00Z', 'nope')).toBe(0); + expect(minutesBetween('nope', '2024-06-15T10:00:00Z')).toBe(0); + }); +}); + +// ── syncWindow ─────────────────────────────────────────────────────────────── + +describe('syncWindow', () => { + it('DAWARICH-WINDOW-001: widens backwards by the lookback and forwards by the lookahead', () => { + const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 2, 3); + expect(window?.from.toISOString()).toBe('2024-06-13T00:00:00.000Z'); + expect(window?.to.toISOString()).toBe('2024-06-20T23:59:59.000Z'); + }); + + it('DAWARICH-WINDOW-002: a zero lookback/lookahead still spans the trip itself', () => { + const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 0, 0); + expect(window?.from.toISOString()).toBe('2024-06-15T00:00:00.000Z'); + expect(window?.to.toISOString()).toBe('2024-06-17T23:59:59.000Z'); + }); + + it('DAWARICH-WINDOW-003: never asks past now — the lookahead is clamped', () => { + const now = new Date('2024-06-16T08:00:00Z'); + const window = syncWindow('2024-06-15', '2024-06-17', now, 1, 5); + expect(window?.to.toISOString()).toBe(now.toISOString()); + }); + + it('DAWARICH-WINDOW-004: an open-ended trip gets the lookback and stops at now', () => { + const now = new Date('2024-06-20T12:00:00Z'); + const window = syncWindow('2024-06-15', null, now, 1, 7); + expect(window?.from.toISOString()).toBe('2024-06-14T00:00:00.000Z'); + expect(window?.to.toISOString()).toBe(now.toISOString()); + }); + + it('DAWARICH-WINDOW-005: an unparsable end date is treated as open-ended, not as a failure', () => { + const now = new Date('2024-06-20T12:00:00Z'); + const window = syncWindow('2024-06-15', 'whenever', now, 1, 7); + expect(window?.to.toISOString()).toBe(now.toISOString()); + }); + + it('DAWARICH-WINDOW-006: a missing start date yields no window at all', () => { + expect(syncWindow(null, '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull(); + }); + + it('DAWARICH-WINDOW-007: an unparsable or empty start date yields no window at all', () => { + expect(syncWindow('gibberish', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull(); + expect(syncWindow('', null, new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull(); + }); + + it('DAWARICH-WINDOW-008: a trip that has not started yet collapses to nothing', () => { + // `now` is before the (already widened) start, so there is no recorded + // stretch to ask about: to <= from. + expect(syncWindow('2024-06-15', null, new Date('2024-06-10T00:00:00Z'), 1, 1)).toBeNull(); + }); +}); + +// ── visitHash ──────────────────────────────────────────────────────────────── + +describe('visitHash', () => { + it('DAWARICH-HASH-001: is a sha256 hex digest', () => { + expect(visitHash(BASE_VISIT)).toMatch(/^[0-9a-f]{64}$/); + }); + + it('DAWARICH-HASH-002: is stable for the same input — a re-run that changed nothing stays quiet', () => { + expect(visitHash(visit())).toBe(visitHash(visit())); + }); + + it('DAWARICH-HASH-003: changes when the visit is renamed', () => { + expect(visitHash(visit({ name: 'Cafe Einstein' }))).not.toBe(visitHash(BASE_VISIT)); + }); + + it('DAWARICH-HASH-004: changes when either timestamp moves', () => { + expect(visitHash(visit({ started_at: '2024-06-15T18:45:00+02:00' }))).not.toBe(visitHash(BASE_VISIT)); + expect(visitHash(visit({ ended_at: '2024-06-15T21:00:00+02:00' }))).not.toBe(visitHash(BASE_VISIT)); + }); + + it('DAWARICH-HASH-005: changes when the place moves or is re-identified', () => { + const moved = visit({ place: { ...BASE_VISIT.place!, latitude: '52.6' } }); + const shifted = visit({ place: { ...BASE_VISIT.place!, longitude: '13.5' } }); + const reidentified = visit({ place: { ...BASE_VISIT.place!, id: 8 } }); + expect(visitHash(moved)).not.toBe(visitHash(BASE_VISIT)); + expect(visitHash(shifted)).not.toBe(visitHash(BASE_VISIT)); + expect(visitHash(reidentified)).not.toBe(visitHash(BASE_VISIT)); + }); + + it('DAWARICH-HASH-006: changes when the status changes', () => { + expect(visitHash(visit({ status: 'suggested' }))).not.toBe(visitHash(BASE_VISIT)); + }); + + it('DAWARICH-HASH-007: ignores fields TREK does not show — a re-scored confidence is not a change', () => { + expect(visitHash(visit({ confidence: '0.11', confidence_band: 'low' }))).toBe(visitHash(BASE_VISIT)); + expect(visitHash(visit({ area_id: 99, user_id: 3 }))).toBe(visitHash(BASE_VISIT)); + }); + + it('DAWARICH-HASH-008: a missing name and an empty name are the same visit', () => { + expect(visitHash(visit({ name: null }))).toBe(visitHash(visit({ name: '' }))); + }); + + it('DAWARICH-HASH-009: a missing place does not throw', () => { + expect(visitHash(visit({ place: null }))).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +// ── toNumber ───────────────────────────────────────────────────────────────── + +describe('toNumber', () => { + it('DAWARICH-NUM-001: passes finite numbers through and rejects the rest', () => { + expect(toNumber(52.5)).toBe(52.5); + expect(toNumber(0)).toBe(0); + expect(toNumber(Number.NaN)).toBeNull(); + expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull(); + }); + + it('DAWARICH-NUM-002: parses numeric strings, rejecting blank and non-numeric ones', () => { + expect(toNumber('13.4')).toBe(13.4); + expect(toNumber(' ')).toBeNull(); + expect(toNumber('north')).toBeNull(); + }); + + it('DAWARICH-NUM-003: null, undefined and objects are not numbers', () => { + expect(toNumber(null)).toBeNull(); + expect(toNumber(undefined)).toBeNull(); + expect(toNumber({})).toBeNull(); + }); +}); + +// ── normalizeVisit ─────────────────────────────────────────────────────────── + +describe('normalizeVisit', () => { + it('DAWARICH-VISIT-001: reduces a full visit to what TREK stores', () => { + expect(normalizeVisit(BASE_VISIT)).toEqual({ + sourceVisitId: '42', + name: 'Cafe Kranzler', + lat: 52.5, + lng: 13.4, + startedAt: '2024-06-15T18:30:00+02:00', + endedAt: '2024-06-15T20:00:00+02:00', + durationMinutes: 90, + localDate: '2024-06-15', + status: 'confirmed', + confidence: 0.87, + confidenceBand: 'high', + countryCodeFromSource: 'DE', + }); + }); + + it('DAWARICH-VISIT-002: an empty visit keeps every optional field null instead of guessing', () => { + const sparse = visit({ + id: '9', + started_at: '2024-06-15T18:30:00Z', + ended_at: '2024-06-15T19:15:00Z', + duration: null, + name: null, + status: null, + confidence: null, + confidence_band: null, + place: null, + }); + expect(normalizeVisit(sparse)).toEqual({ + sourceVisitId: '9', + name: 'Unnamed stay', + lat: null, + lng: null, + startedAt: '2024-06-15T18:30:00Z', + endedAt: '2024-06-15T19:15:00Z', + durationMinutes: 45, + localDate: '2024-06-15', + status: 'suggested', + confidence: null, + confidenceBand: null, + countryCodeFromSource: null, + }); + }); + + it('DAWARICH-VISIT-003: a whitespace-only name falls back to the placeholder', () => { + expect(normalizeVisit(visit({ name: ' ' }))?.name).toBe('Unnamed stay'); + }); + + it('DAWARICH-VISIT-004: a place without coordinates yields null lat/lng, not zeros', () => { + const result = normalizeVisit(visit({ place: { latitude: null, longitude: null, id: null } })); + expect(result?.lat).toBeNull(); + expect(result?.lng).toBeNull(); + }); + + it('DAWARICH-VISIT-005: "confirmed" is the only status taken at face value', () => { + expect(normalizeVisit(visit({ status: 'confirmed' }))?.status).toBe('confirmed'); + expect(normalizeVisit(visit({ status: 'suggested' }))?.status).toBe('suggested'); + }); + + it('DAWARICH-VISIT-006: an unknown status falls to "suggested" — the side that asks, not the side that acts', () => { + // "declined" stopped existing in Dawarich 1.12.0; a future value must not + // silently become a confirmation. + expect(normalizeVisit(visit({ status: 'declined' }))?.status).toBe('suggested'); + expect(normalizeVisit(visit({ status: 'something_new' }))?.status).toBe('suggested'); + expect(normalizeVisit(visit({ status: null }))?.status).toBe('suggested'); + }); + + it('DAWARICH-VISIT-007: a positive duration is read as seconds', () => { + expect(normalizeVisit(visit({ duration: 5400 }))?.durationMinutes).toBe(90); + expect(normalizeVisit(visit({ duration: '120' }))?.durationMinutes).toBe(2); + }); + + it('DAWARICH-VISIT-008: a missing or zero duration is recomputed from the two timestamps', () => { + // 18:30 → 20:00 is 90 minutes whichever way it is derived. + expect(normalizeVisit(visit({ duration: null }))?.durationMinutes).toBe(90); + expect(normalizeVisit(visit({ duration: 0 }))?.durationMinutes).toBe(90); + expect(normalizeVisit(visit({ duration: -60 }))?.durationMinutes).toBe(90); + expect(normalizeVisit(visit({ duration: 'unknown' }))?.durationMinutes).toBe(90); + }); + + it('DAWARICH-VISIT-009: a two-letter country code from the source is upper-cased', () => { + expect(normalizeVisit(visit())?.countryCodeFromSource).toBe('DE'); + const upper = visit({ place: { ...BASE_VISIT.place!, country_code: 'FR' } }); + expect(normalizeVisit(upper)?.countryCodeFromSource).toBe('FR'); + }); + + it('DAWARICH-VISIT-010: anything that is not a two-letter code is dropped', () => { + const three = visit({ place: { ...BASE_VISIT.place!, country_code: 'deu' } }); + const empty = visit({ place: { ...BASE_VISIT.place!, country_code: '' } }); + const absent = visit({ place: { latitude: '52.5', longitude: '13.4', id: 7 } }); + expect(normalizeVisit(three)?.countryCodeFromSource).toBeNull(); + expect(normalizeVisit(empty)?.countryCodeFromSource).toBeNull(); + expect(normalizeVisit(absent)?.countryCodeFromSource).toBeNull(); + }); + + it('DAWARICH-VISIT-011: the local date comes from the source offset, not from the host clock', () => { + const tokyoLateNight = visit({ + started_at: '2024-06-15T23:30:00+09:00', + ended_at: '2024-06-16T00:30:00+09:00', + }); + expect(normalizeVisit(tokyoLateNight)?.localDate).toBe('2024-06-15'); + }); + + it('DAWARICH-VISIT-012: a visit without an id is unusable', () => { + expect(normalizeVisit(malformedVisit({ id: null }))).toBeNull(); + expect(normalizeVisit(malformedVisit({ id: undefined }))).toBeNull(); + }); + + it('DAWARICH-VISIT-013: non-string timestamps are unusable', () => { + expect(normalizeVisit(malformedVisit({ started_at: 1_718_476_200 }))).toBeNull(); + expect(normalizeVisit(malformedVisit({ ended_at: null }))).toBeNull(); + }); + + it('DAWARICH-VISIT-014: an unparsable start timestamp is unusable', () => { + expect(normalizeVisit(visit({ started_at: 'last Tuesday' }))).toBeNull(); + }); + + it('DAWARICH-VISIT-015: an unreadable end collapses onto the arrival rather than staying unreadable', () => { + // The guard checks `started_at` alone, so an unreadable end does not drop + // the visit. It is repaired instead of passed through: kept as-is it would + // render as "Invalid Date" beside a perfectly good arrival time. + const result = normalizeVisit(visit({ duration: null, ended_at: 'sometime' })); + expect(result?.endedAt).toBe(new Date(Date.parse(result!.startedAt)).toISOString()); + expect(result?.durationMinutes).toBe(0); + }); + + it('DAWARICH-VISIT-016: an unreadable end falls back to the reported duration', () => { + // With a duration to go on, the end is the arrival plus that span — which is + // what the stay actually was, and what the two timestamps then agree on. + const result = normalizeVisit(visit({ duration: 1800, ended_at: 'sometime' })); + expect(Date.parse(result!.endedAt) - Date.parse(result!.startedAt)).toBe(1_800_000); + expect(result?.durationMinutes).toBe(30); + }); +}); + +// ── distanceMeters ─────────────────────────────────────────────────────────── + +describe('distanceMeters', () => { + it('DAWARICH-GEO-001: a point is zero metres from itself', () => { + expect(distanceMeters(52.52, 13.405, 52.52, 13.405)).toBe(0); + }); + + it('DAWARICH-GEO-002: a small north-south step measures in metres', () => { + // 0.0009° of latitude is almost exactly 100 m anywhere on earth. + expect(distanceMeters(52.52, 13.405, 52.5209, 13.405)).toBeCloseTo(100, 0); + }); + + it('DAWARICH-GEO-003: a degree of longitude is ~111 km at the equator', () => { + expect(distanceMeters(0, 0, 0, 1)).toBeCloseTo(111_195, -2); + }); + + it('DAWARICH-GEO-004: a degree of longitude shrinks with the cosine of the latitude', () => { + // At 60° north a degree of longitude is half what it is at the equator. + expect(distanceMeters(60, 0, 60, 1)).toBeCloseTo(111_195 / 2, -2); + }); + + it('DAWARICH-GEO-005: distance is symmetric', () => { + const there = distanceMeters(48.85, 2.35, 52.52, 13.405); + const back = distanceMeters(52.52, 13.405, 48.85, 2.35); + expect(there).toBeCloseTo(back, 6); + }); +}); + +// ── simplify ───────────────────────────────────────────────────────────────── + +describe('simplify', () => { + it('DAWARICH-GEO-006: a straight line collapses to its two ends', () => { + expect(simplify([[0, 0], [0, 0.5], [0, 1]], 0.00005)).toEqual([[0, 0], [0, 1]]); + }); + + it('DAWARICH-GEO-007: a long straight run collapses no matter how many points it has', () => { + const straight: Array<[number, number]> = Array.from({ length: 500 }, (_, i) => [0, i / 500]); + expect(simplify(straight, 0.00005)).toEqual([[0, 0], [0, 499 / 500]]); + }); + + it('DAWARICH-GEO-008: a corner survives', () => { + expect(simplify([[0, 0], [0, 1], [1, 1]], 0.00005)).toEqual([[0, 0], [0, 1], [1, 1]]); + }); + + it('DAWARICH-GEO-009: a deviation under the tolerance is dropped, one over it is kept', () => { + const jitter: Array<[number, number]> = [[0, 0], [0.00001, 0.5], [0, 1]]; + const corner: Array<[number, number]> = [[0, 0], [0.001, 0.5], [0, 1]]; + expect(simplify(jitter, 0.00005)).toHaveLength(2); + expect(simplify(corner, 0.00005)).toHaveLength(3); + }); + + it('DAWARICH-GEO-010: two points or fewer are returned untouched', () => { + const pair: Array<[number, number]> = [[0, 0], [1, 1]]; + expect(simplify(pair, 0.00005)).toBe(pair); + expect(simplify([], 0.00005)).toEqual([]); + }); + + it('DAWARICH-GEO-011: the first and last point are always kept', () => { + const zigzag: Array<[number, number]> = [[0, 0], [1, 0.25], [0, 0.5], [1, 0.75], [0, 1]]; + const out = simplify(zigzag, 0.00005); + expect(out[0]).toEqual([0, 0]); + expect(out[out.length - 1]).toEqual([0, 1]); + }); +}); + +// ── capPoints ──────────────────────────────────────────────────────────────── + +describe('capPoints', () => { + it('DAWARICH-GEO-012: a line already under the cap is untouched', () => { + const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2]]; + expect(capPoints(line, 5)).toBe(line); + expect(capPoints(line, 3)).toBe(line); + }); + + it('DAWARICH-GEO-013: thins to the cap on an even stride', () => { + const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]]; + expect(capPoints(line, 3)).toEqual([[0, 0], [2, 2], [4, 4]]); + }); + + it('DAWARICH-GEO-014: the line still starts and ends where the day did', () => { + const line: Array<[number, number]> = Array.from({ length: 1000 }, (_, i) => [i / 1000, 0]); + const out = capPoints(line, 100); + expect(out).toHaveLength(100); + expect(out[0]).toEqual(line[0]); + expect(out[out.length - 1]).toEqual(line[line.length - 1]); + }); + + it('DAWARICH-GEO-015: a cap below two is refused rather than producing a degenerate line', () => { + const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]]; + expect(capPoints(line, 1)).toBe(line); + expect(capPoints(line, 0)).toBe(line); + }); +}); + +// ── geoJsonToLatLng ────────────────────────────────────────────────────────── + +describe('geoJsonToLatLng', () => { + it('DAWARICH-GEO-016: swaps GeoJSON [lng, lat] into TREK [lat, lng]', () => { + expect(geoJsonToLatLng([[13.4, 52.5]])).toEqual([[52.5, 13.4]]); + }); + + it('DAWARICH-GEO-017: parses numeric strings as coordinates', () => { + expect(geoJsonToLatLng([['13.4', '52.5']])).toEqual([[52.5, 13.4]]); + }); + + it('DAWARICH-GEO-018: skips pairs that are not pairs', () => { + expect(geoJsonToLatLng([[13.4], 'nope', null, {}, []])).toEqual([]); + }); + + it('DAWARICH-GEO-019: skips pairs that are not numbers', () => { + expect(geoJsonToLatLng([['east', 'north'], [null, 52.5], [13.4, undefined]])).toEqual([]); + }); + + it('DAWARICH-GEO-020: keeps the exact range limits and drops anything past them', () => { + expect(geoJsonToLatLng([[180, 90], [-180, -90]])).toEqual([[90, 180], [-90, -180]]); + expect(geoJsonToLatLng([[180.1, 10], [-180.1, 10], [10, 90.1], [10, -90.1]])).toEqual([]); + }); + + it('DAWARICH-GEO-021: keeps the usable pairs out of a mixed list', () => { + expect(geoJsonToLatLng([[13.4, 52.5], 'nope', [999, 999], [2.35, 48.85]])).toEqual([ + [52.5, 13.4], + [48.85, 2.35], + ]); + }); + + it('DAWARICH-GEO-022: a non-array is an empty line, not a throw', () => { + expect(geoJsonToLatLng('nope')).toEqual([]); + expect(geoJsonToLatLng(null)).toEqual([]); + expect(geoJsonToLatLng(undefined)).toEqual([]); + }); + + it('DAWARICH-GEO-023: ignores anything past the first two entries of a pair', () => { + // GeoJSON positions may carry an elevation; TREK draws in two dimensions. + expect(geoJsonToLatLng([[13.4, 52.5, 34]])).toEqual([[52.5, 13.4]]); + }); +}); + +// ── bucketPointsByDay ──────────────────────────────────────────────────────── + +describe('bucketPointsByDay', () => { + const acrossMidnight = (): DawarichSlimPoint[] => [ + point('2024-06-15T10:00:00Z', 10, 10), + point('2024-06-15T23:59:59Z', 10, 11), + point('2024-06-16T00:00:00Z', 11, 11), + point('2024-06-16T10:00:00Z', 11, 12), + ]; + + it('DAWARICH-BUCKET-001: midnight is exact — a point at 00:00:00 belongs to the new day', () => { + const days = bucketPointsByDay(acrossMidnight(), 0); + expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']); + expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T10:00:00.000Z'); + expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T23:59:59.000Z'); + expect(days[1]!.segments[0]!.startedAt).toBe('2024-06-16T00:00:00.000Z'); + expect(days[1]!.segments[0]!.endedAt).toBe('2024-06-16T10:00:00.000Z'); + }); + + it('DAWARICH-BUCKET-002: the seam is stitched — a day starts where the previous one ended', () => { + const days = bucketPointsByDay(acrossMidnight(), 0); + const previousLast = days[0]!.segments[0]!.points.at(-1); + // The stitched point is drawn but does not move the day's own times. + expect(days[1]!.segments[0]!.points[0]).toEqual(previousLast); + expect(days[1]!.segments[0]!.points).toEqual([[10, 11], [11, 11], [11, 12]]); + }); + + it('DAWARICH-BUCKET-003: the first day is not stitched to anything', () => { + const days = bucketPointsByDay( + [point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)], + 0, + ); + expect(days[0]!.segments[0]!.points).toEqual([[10, 10], [10, 11]]); + }); + + it('DAWARICH-BUCKET-004: the offset decides the day — an early UTC point is still yesterday in New York', () => { + const days = bucketPointsByDay( + [point('2024-06-15T02:00:00Z', 10, 10), point('2024-06-15T03:00:00Z', 11, 11)], + -300, + ); + expect(days).toHaveLength(1); + expect(days[0]!.date).toBe('2024-06-14'); + // The segment times stay the real instants; only the bucket is local. + expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T02:00:00.000Z'); + }); + + it('DAWARICH-BUCKET-005: a positive offset pushes a late UTC point into tomorrow', () => { + const days = bucketPointsByDay( + [point('2024-06-15T22:00:00Z', 10, 10), point('2024-06-15T23:00:00Z', 11, 11)], + 540, + ); + expect(days.map((d) => d.date)).toEqual(['2024-06-16']); + }); + + it('DAWARICH-BUCKET-006: points arriving out of order are sorted by time', () => { + const days = bucketPointsByDay( + [ + point('2024-06-15T18:00:00Z', 11, 11), + point('2024-06-15T06:00:00Z', 10, 10), + point('2024-06-15T12:00:00Z', 10, 11), + ], + 0, + ); + expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T06:00:00.000Z'); + expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T18:00:00.000Z'); + expect(days[0]!.segments[0]!.points[0]).toEqual([10, 10]); + }); + + it('DAWARICH-BUCKET-007: unusable points are dropped and a day that loses its line is not emitted', () => { + const days = bucketPointsByDay( + [ + { latitude: 'north', longitude: 10, timestamp: 1_718_445_600 }, + { latitude: 95, longitude: 10, timestamp: 1_718_445_601 }, + { latitude: 10, longitude: 200, timestamp: 1_718_445_602 }, + { latitude: 10, longitude: 10, timestamp: null } as unknown as DawarichSlimPoint, + point('2024-06-15T10:00:00Z', 10, 10), + ], + 0, + ); + // Only one usable point survived, so there is no line to draw. + expect(days).toEqual([]); + }); + + it('DAWARICH-BUCKET-008: an empty recording is an empty result', () => { + expect(bucketPointsByDay([], 0)).toEqual([]); + }); + + it('DAWARICH-BUCKET-009: honours the per-day cap and the tolerance', () => { + const days = bucketPointsByDay( + [ + point('2024-06-15T00:00:00Z', 0, 0), + point('2024-06-15T01:00:00Z', 1, 0), + point('2024-06-15T02:00:00Z', 0, 1), + point('2024-06-15T03:00:00Z', 1, 1), + point('2024-06-15T04:00:00Z', 0, 2), + ], + 0, + { maxPointsPerDay: 3, epsilon: 0.00005 }, + ); + const line = days[0]!.segments[0]!.points; + expect(line).toHaveLength(3); + expect(line[0]).toEqual([0, 0]); + expect(line.at(-1)).toEqual([0, 2]); + }); + + it('DAWARICH-BUCKET-010: a recorded day carries no mode and no measured distance', () => { + const days = bucketPointsByDay( + [point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)], + 0, + ); + expect(days[0]!.segments[0]!.mode).toBeNull(); + expect(days[0]!.segments[0]!.distanceMeters).toBeNull(); + }); +}); + +// ── bucketTracksByDay ──────────────────────────────────────────────────────── + +describe('bucketTracksByDay', () => { + it('DAWARICH-TRACK-001: a track inside one day stays whole and keeps its measured distance', () => { + const days = bucketTracksByDay([ + track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]], { + distance: 4321, + dominant_mode: 'car', + }), + ]); + expect(days).toEqual([ + { + date: '2024-06-15', + segments: [ + { + // GeoJSON [lng, lat] in, TREK [lat, lng] out. + points: [[10, 10], [11, 11], [12, 11]], + mode: 'car', + startedAt: '2024-06-15T10:00:00Z', + endedAt: '2024-06-15T12:00:00Z', + distanceMeters: 4321, + }, + ], + }, + ]); + }); + + it('DAWARICH-TRACK-002: the local day comes from the offset the track carries', () => { + // 00:30+02:00 is still 22:30Z the day before, but the recording user saw + // the 16th, which is the day TREK must file it under. + const days = bucketTracksByDay([ + track('2024-06-16T00:30:00+02:00', '2024-06-16T01:30:00+02:00', [[10, 10], [11, 11], [11, 12]]), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-16']); + }); + + it('DAWARICH-TRACK-003: a track over midnight is split, and the two halves meet at midnight', () => { + const days = bucketTracksByDay([ + track( + '2024-06-15T23:00:00Z', + '2024-06-16T01:00:00Z', + [[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]], + { distance: 9000, dominant_mode: 'walk' }, + ), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']); + + const first = days[0]!.segments[0]!; + const second = days[1]!.segments[0]!; + expect(first.startedAt).toBe('2024-06-15T23:00:00.000Z'); + expect(first.endedAt).toBe('2024-06-16T00:00:00.000Z'); + expect(second.startedAt).toBe('2024-06-16T00:00:00.000Z'); + expect(second.endedAt).toBe('2024-06-16T01:00:00Z'); + // The cut point belongs to both halves, so the line has no gap. + expect(second.points[0]).toEqual(first.points.at(-1)); + expect(first.mode).toBe('walk'); + expect(second.mode).toBe('walk'); + }); + + it('DAWARICH-TRACK-004: a split piece reports no distance — only a whole track measured one', () => { + const days = bucketTracksByDay([ + track( + '2024-06-15T23:00:00Z', + '2024-06-16T01:00:00Z', + [[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]], + { distance: 9000 }, + ), + ]); + expect(days.flatMap((d) => d.segments).map((s) => s.distanceMeters)).toEqual([null, null]); + }); + + it('DAWARICH-TRACK-005: a track spanning three days is cut twice, by index proportion of its span', () => { + const days = bucketTracksByDay([ + track( + '2024-06-15T12:00:00Z', + '2024-06-17T12:00:00Z', + [[10, 10], [10.5, 11], [11, 10], [11.5, 11], [12, 10], [12.5, 11], [13, 10]], + { distance: 100 }, + ), + ]); + expect( + days.map((d) => ({ date: d.date, from: d.segments[0]!.startedAt, to: d.segments[0]!.endedAt })), + ).toEqual([ + { date: '2024-06-15', from: '2024-06-15T12:00:00.000Z', to: '2024-06-16T00:00:00.000Z' }, + { date: '2024-06-16', from: '2024-06-16T00:00:00.000Z', to: '2024-06-17T00:00:00.000Z' }, + { date: '2024-06-17', from: '2024-06-17T00:00:00.000Z', to: '2024-06-17T12:00:00Z' }, + ]); + }); + + it('DAWARICH-TRACK-006: days come back in calendar order however the features arrived', () => { + const days = bucketTracksByDay([ + track('2024-06-17T08:00:00Z', '2024-06-17T09:00:00Z', [[10, 10], [11, 11], [11, 12]]), + track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]), + track('2024-06-16T06:00:00Z', '2024-06-16T07:00:00Z', [[30, 30], [31, 31], [31, 32]]), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16', '2024-06-17']); + }); + + it('DAWARICH-TRACK-007: segments within a day come back in start order', () => { + const days = bucketTracksByDay([ + track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]), + track('2024-06-15T06:00:00Z', '2024-06-15T07:00:00Z', [[30, 30], [31, 31], [31, 32]]), + track('2024-06-15T12:00:00Z', '2024-06-15T13:00:00Z', [[40, 40], [41, 41], [41, 42]]), + ]); + expect(days).toHaveLength(1); + expect(days[0]!.segments.map((s) => s.startedAt)).toEqual([ + '2024-06-15T06:00:00Z', + '2024-06-15T12:00:00Z', + '2024-06-15T18:00:00Z', + ]); + }); + + it('DAWARICH-TRACK-008: a track without both timestamps is skipped', () => { + const days = bucketTracksByDay([ + track(undefined, '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]), + track('2024-06-15T10:00:00Z', undefined, [[10, 10], [11, 11], [11, 12]]), + { geometry: { coordinates: [[10, 10], [11, 11]] }, properties: null }, + ]); + expect(days).toEqual([]); + }); + + it('DAWARICH-TRACK-009: a track without a drawable line is skipped', () => { + const days = bucketTracksByDay([ + track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10]]), + track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', 'nope'), + { geometry: null, properties: { start_at: '2024-06-15T10:00:00Z', end_at: '2024-06-15T12:00:00Z' } }, + ]); + expect(days).toEqual([]); + }); + + it('DAWARICH-TRACK-010: a backwards or zero-length track is treated as whole, on its start day', () => { + const days = bucketTracksByDay([ + track('2024-06-16T01:00:00Z', '2024-06-15T23:00:00Z', [[10, 10], [11, 11], [11, 12]], { + distance: 50, + }), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-16']); + expect(days[0]!.segments[0]!.distanceMeters).toBe(50); + }); + + it('DAWARICH-TRACK-011: a two-point track over midnight is not split at all', () => { + // One segment cannot become two drawable pieces: cutting it would hand one + // day a single point and the other nothing, and the tail would vanish. So + // the whole track goes on the day it started, with its real times intact — + // the split is declined rather than faked. + const days = bucketTracksByDay([ + track('2024-06-15T23:00:00Z', '2024-06-16T01:00:00Z', [[10, 10], [11, 12]], { distance: 100 }), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-15']); + expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-16T01:00:00Z'); + // Whole, so it still reports the distance Dawarich actually measured. + expect(days[0]!.segments[0]!.distanceMeters).toBe(100); + }); + + it('DAWARICH-TRACK-014: enough points to split across midnight still splits', () => { + // The counterpart to 011: three segments over one midnight leave both days + // a drawable line, so the approximation is applied. + const days = bucketTracksByDay([ + track('2024-06-15T22:00:00Z', '2024-06-16T02:00:00Z', [[10, 10], [10.5, 10.5], [11, 11], [11.5, 11.5]]), + ]); + expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']); + for (const day of days) expect(day.segments[0]!.points.length).toBeGreaterThanOrEqual(2); + }); + + it('DAWARICH-TRACK-012: honours the per-day cap and the tolerance', () => { + const zigzag = Array.from( + { length: 40 }, + (_, i): [number, number] => [10 + i / 10, i % 2 === 0 ? 10 : 11], + ); + const days = bucketTracksByDay([track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', zigzag)], { + maxPointsPerDay: 5, + epsilon: 0.00005, + }); + expect(days[0]!.segments[0]!.points).toHaveLength(5); + }); + + it('DAWARICH-TRACK-013: an empty feature list is an empty result', () => { + expect(bucketTracksByDay([])).toEqual([]); + }); +}); + +// ── countPoints ────────────────────────────────────────────────────────────── + +describe('countPoints', () => { + const day = (...segments: Array>): DawarichTrackDay => ({ + date: '2024-06-15', + segments: segments.map((points) => ({ + points, + mode: null, + startedAt: '2024-06-15T10:00:00Z', + endedAt: '2024-06-15T12:00:00Z', + distanceMeters: null, + })), + }); + + it('DAWARICH-COUNT-001: nothing drawn is zero', () => { + expect(countPoints([])).toBe(0); + expect(countPoints([day()])).toBe(0); + }); + + it('DAWARICH-COUNT-002: sums every segment of every day', () => { + const days = [day([[0, 0], [1, 1]], [[0, 0], [1, 1], [2, 2]]), day([[0, 0], [1, 1]])]; + expect(countPoints(days)).toBe(7); + }); + + it('DAWARICH-COUNT-003: agrees with what the bucketing actually produced', () => { + const days = bucketTracksByDay([ + track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]), + track('2024-06-16T10:00:00Z', '2024-06-16T12:00:00Z', [[20, 20], [21, 21], [21, 22]]), + ]); + expect(countPoints(days)).toBe(6); + }); +}); diff --git a/server/tests/unit/nest/public-stats.controller.test.ts b/server/tests/unit/nest/public-stats.controller.test.ts index aaae15837..50f24b113 100644 --- a/server/tests/unit/nest/public-stats.controller.test.ts +++ b/server/tests/unit/nest/public-stats.controller.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { Request } from 'express'; +import { PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared'; import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller'; import type { AtlasService } from '../../../src/nest/atlas/atlas.service'; import { RateLimitService } from '../../../src/nest/common/rate-limit.service'; @@ -22,8 +23,15 @@ import type { User } from '../../../src/types'; // `null` rather than `undefined` for "no user": passing undefined would trip the // default parameter and hand back a request that still has one. +// `apiToken` is the read grant the guard resolves alongside the user (#2279); +// the full one here, so these cases still test what they always tested. +const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] }; const req = (userId: number | null = 7) => - ({ headers: {}, user: userId === null ? undefined : ({ id: userId } as User) }) as Request; + ({ + headers: {}, + user: userId === null ? undefined : ({ id: userId } as User), + apiToken: FULL_GRANT, + }) as Request; const travel = (o: Partial> = {}) => ({ countries: ['JP', 'IT'], diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 7032ec653..7a20d615e 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -87,7 +87,7 @@ export default defineConfig({ 'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 }, 'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 }, 'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 }, - 'src/nest/integrations/**/*.ts': { statements: 72, branches: 56, functions: 76, lines: 74 }, + 'src/nest/integrations/**/*.ts': { statements: 78, branches: 68, functions: 78, lines: 80 }, 'src/nest/journey/**/*.ts': { statements: 91, branches: 84, functions: 88, lines: 93 }, 'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 }, 'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 }, diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 318080a93..cb101b5ce 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -612,6 +612,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'استيراد الأماكن من قائمة Naver Maps مشتركة', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'مزامنة الرحلات من خادم AirTrail الخاص بك', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'قراءة الزيارات والمسارات المسجَّلة من خادم Dawarich يربطه كل قارئ بنفسه', 'admin.addons.catalog.llm_parsing.name': 'التحليل بالذكاء الاصطناعي', 'admin.addons.catalog.llm_parsing.description': 'يقرأ الحجوزات التي يعجز المحلل المدمج عن فهمها، بنموذج ذكاء اصطناعي تختاره', 'admin.addons.enabled': 'مفعّل', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 60db5f161..125618074 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -605,6 +605,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importe lugares de uma lista compartilhada do Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sincronize voos da sua instância AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Leia visitas e rotas gravadas de uma instância do Dawarich que cada leitor conecta por conta própria', 'admin.addons.catalog.llm_parsing.name': 'Análise por IA', 'admin.addons.catalog.llm_parsing.description': 'Lê reservas que o analisador integrado não entende, usando um modelo de IA à sua escolha', 'admin.addons.enabled': 'Ativado', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index cc540096b..5dbce0c29 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -224,6 +224,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importa llocs des d’una llista compartida de Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sincronitza vols des de la teva instància d’AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Llegeix estades i recorreguts enregistrats d’una instància de Dawarich que cada lector connecta pel seu compte', 'admin.addons.catalog.llm_parsing.name': 'Anàlisi amb IA', 'admin.addons.catalog.llm_parsing.description': 'Llegeix reserves que l’analitzador integrat no entén, amb un model d’IA que triïs', 'admin.addons.enabled': 'Actiu', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 7130a43fd..086cb047e 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -594,6 +594,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importujte místa ze sdíleného seznamu Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Synchronizujte lety ze své instance AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Načtěte návštěvy a zaznamenané trasy z instance Dawarich, kterou si každý čtenář připojí sám', 'admin.addons.catalog.llm_parsing.name': 'Analýza pomocí AI', 'admin.addons.catalog.llm_parsing.description': 'Přečte rezervace, které vestavěný analyzátor nezvládne, pomocí zvoleného modelu AI', 'admin.addons.catalog.roadtrip.name': 'Cesta autem', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index d980385e4..f58d44fd8 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -611,6 +611,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Orte aus einer geteilten Naver-Maps-Liste importieren', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Flüge aus deiner AirTrail-Instanz synchronisieren', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Aufenthalte und aufgezeichnete Routen aus einer Dawarich-Instanz lesen, die jeder selbst verbindet', 'admin.addons.catalog.llm_parsing.name': 'KI-Auswertung', 'admin.addons.catalog.llm_parsing.description': 'Liest Buchungen, an denen der eingebaute Parser scheitert, mit einem KI-Modell deiner Wahl', 'admin.addons.enabled': 'Aktiviert', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 72e74f575..abf876a4f 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -647,6 +647,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Import places from a shared Naver Maps list', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sync flights from your AirTrail instance', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Read visits and recorded routes from a Dawarich instance each reader connects themselves', 'admin.addons.catalog.llm_parsing.name': 'AI Parsing', 'admin.addons.catalog.llm_parsing.description': 'Reads bookings the built-in parser cannot, using an AI model you choose', 'admin.addons.enabled': 'Enabled', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 66c11e059..5ab67bbcf 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -596,6 +596,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importa lugares desde una lista compartida de Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sincroniza vuelos desde tu instancia de AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Lee estancias y rutas grabadas de una instancia de Dawarich que cada usuario conecta por su cuenta', 'admin.addons.catalog.llm_parsing.name': 'Análisis con IA', 'admin.addons.catalog.llm_parsing.description': 'Lee reservas que el analizador integrado no entiende, con un modelo de IA que tú elijas', 'admin.addons.enabled': 'Activo', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 3a5086564..c85fa6ad2 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -617,6 +617,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importez des lieux depuis une liste Naver Maps partagée', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Synchronisez les vols depuis votre instance AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Lisez les séjours et les trajets enregistrés depuis une instance Dawarich que chaque utilisateur connecte lui-même', 'admin.addons.catalog.llm_parsing.name': 'Analyse par IA', 'admin.addons.catalog.llm_parsing.description': 'Lit les réservations que l’analyseur intégré ne comprend pas, via un modèle d’IA de votre choix', 'admin.addons.enabled': 'Activé', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 2dd5d5b1c..382e078a4 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -668,6 +668,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Εισαγωγή τοποθεσιών από κοινόχρηστη λίστα Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Συγχρονισμός πτήσεων από τη δική σας εγκατάσταση AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Ανάγνωση επισκέψεων και καταγεγραμμένων διαδρομών από μια εγκατάσταση Dawarich που συνδέει ο κάθε χρήστης μόνος του', 'admin.addons.catalog.llm_parsing.name': 'Ανάλυση με τεχνητή νοημοσύνη', 'admin.addons.catalog.llm_parsing.description': 'Διαβάζει κρατήσεις που δεν καταλαβαίνει ο ενσωματωμένος αναλυτής, με μοντέλο ΤΝ της επιλογής σας', 'admin.addons.enabled': 'Ενεργοποιημένο', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index c29d73194..795a91ea1 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -616,6 +616,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Helyek importálása megosztott Naver Maps-listából', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Járatok szinkronizálása az AirTrail-példányodból', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Tartózkodások és rögzített útvonalak olvasása egy Dawarich-példányból, amelyet minden olvasó maga köt be', 'admin.addons.catalog.llm_parsing.name': 'MI-elemzés', 'admin.addons.catalog.llm_parsing.description': 'Beolvassa azokat a foglalásokat, amelyeket a beépített értelmező nem tud, az általad választott MI-modellel', 'admin.addons.enabled': 'Engedélyezve', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 83bcf7017..c1d3e3ec9 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -657,6 +657,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Impor tempat dari daftar Naver Maps yang dibagikan', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sinkronkan penerbangan dari instans AirTrail milikmu', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Baca kunjungan dan rute terekam dari instans Dawarich yang dihubungkan sendiri oleh tiap pembaca', 'admin.addons.catalog.llm_parsing.name': 'Analisis AI', 'admin.addons.catalog.llm_parsing.description': 'Membaca pemesanan yang tidak dipahami pengurai bawaan, dengan model AI pilihanmu', 'admin.addons.enabled': 'Aktif', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index d92d7ae4e..ca95d58bf 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -612,6 +612,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importa luoghi da un elenco condiviso di Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Sincronizza i voli dalla tua istanza AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Legge le soste e i percorsi registrati da un’istanza Dawarich che ogni utente collega da sé', 'admin.addons.catalog.llm_parsing.name': 'Analisi con IA', 'admin.addons.catalog.llm_parsing.description': 'Legge le prenotazioni che l’analizzatore integrato non riesce a interpretare, con un modello di IA a tua scelta', 'admin.addons.enabled': 'Abilitato', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 154a5d73f..a11ce3502 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -621,6 +621,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': '共有された Naver マップのリストから場所をインポート', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': '自分の AirTrail から フライトを同期', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + '各自が接続した Dawarich インスタンスから、滞在と記録されたルートを読み取ります', 'admin.addons.catalog.llm_parsing.name': 'AI 解析', 'admin.addons.catalog.llm_parsing.description': '内蔵パーサーが読めない予約を、選んだ AI モデルで読み取ります', 'admin.addons.enabled': '有効', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index f4b0e12e3..8b4d231b8 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -621,6 +621,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': '공유된 네이버 지도 목록에서 장소 가져오기', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'AirTrail 인스턴스에서 항공편 동기화', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + '사용자가 각자 연결한 Dawarich 인스턴스에서 방문 기록과 기록된 경로를 읽어옵니다', 'admin.addons.catalog.llm_parsing.name': 'AI 분석', 'admin.addons.catalog.llm_parsing.description': '기본 분석기가 읽지 못하는 예약을 원하는 AI 모델로 읽어냅니다', 'admin.addons.enabled': '활성화됨', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 05339af7b..e0b04c247 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -609,6 +609,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importeer plaatsen uit een gedeelde Naver Maps-lijst', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Synchroniseer vluchten vanuit je AirTrail-instantie', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Lees bezoeken en opgenomen routes uit een Dawarich-instantie die elke lezer zelf koppelt', 'admin.addons.catalog.llm_parsing.name': 'AI-analyse', 'admin.addons.catalog.llm_parsing.description': 'Leest boekingen die de ingebouwde parser niet begrijpt, met een AI-model naar keuze', 'admin.addons.enabled': 'Ingeschakeld', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index f44a24937..43eb6f078 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -607,6 +607,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importuj miejsca z udostępnionej listy Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Synchronizuj loty ze swojej instancji AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Odczytuj pobyty i zapisane trasy z instancji Dawarich, którą każdy użytkownik podłącza sam', 'admin.addons.catalog.llm_parsing.name': 'Analiza AI', 'admin.addons.catalog.llm_parsing.description': 'Odczytuje rezerwacje, których nie rozumie wbudowany parser, przy użyciu wybranego modelu AI', 'admin.addons.enabled': 'Włączone', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 61babfc73..d7bff554f 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -612,6 +612,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Импорт мест из общего списка Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Синхронизация рейсов с вашего сервера AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Чтение посещений и записанных маршрутов из экземпляра Dawarich, который каждый пользователь подключает сам', 'admin.addons.catalog.llm_parsing.name': 'Разбор с помощью ИИ', 'admin.addons.catalog.llm_parsing.description': 'Читает брони, которые не понимает встроенный анализатор, с помощью выбранной вами модели ИИ', 'admin.addons.enabled': 'Включено', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 80f0bff35..967a7a260 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -670,6 +670,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Importera platser från en delad Naver Maps-lista', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Synkronisera flyg från din AirTrail-instans', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Läs vistelser och inspelade rutter från en Dawarich-instans som varje läsare ansluter själv', 'admin.addons.catalog.llm_parsing.name': 'AI-tolkning', 'admin.addons.catalog.llm_parsing.description': 'Läser bokningar som den inbyggda tolken inte klarar, med en AI-modell du väljer', 'admin.addons.enabled': 'Aktiverad', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 9a46d58e7..4d41daf19 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -655,6 +655,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Paylaşılan bir Naver Haritalar listesinden yerleri içe aktarın', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'AirTrail örneğinizden uçuşları eşitleyin', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Her kullanıcının kendi bağladığı bir Dawarich sunucusundan ziyaretleri ve kaydedilen rotaları okuyun', 'admin.addons.catalog.llm_parsing.name': 'Yapay zekâ ile ayrıştırma', 'admin.addons.catalog.llm_parsing.description': 'Yerleşik ayrıştırıcının okuyamadığı rezervasyonları, seçtiğiniz yapay zekâ modeliyle okur', 'admin.addons.enabled': 'Etkinleştirilmiş', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 2e9466081..43213fbbd 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -609,6 +609,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Імпорт місць зі спільного списку Naver Maps', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Синхронізація рейсів із вашого сервера AirTrail', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Читає перебування та записані маршрути із сервера Dawarich, який кожен користувач підключає сам', 'admin.addons.catalog.llm_parsing.name': 'Розбір за допомогою ШІ', 'admin.addons.catalog.llm_parsing.description': 'Читає бронювання, які не розпізнає вбудований аналізатор, за допомогою обраної вами моделі ШІ', 'admin.addons.enabled': 'Увімкнено', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 846223ce0..af4c3c6f6 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -611,6 +611,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': 'Nhập địa điểm từ danh sách Naver Maps được chia sẻ', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': 'Đồng bộ chuyến bay từ máy chủ AirTrail của bạn', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + 'Đọc các điểm lưu trú và lộ trình đã ghi từ phiên bản Dawarich mà mỗi người tự kết nối', 'admin.addons.catalog.llm_parsing.name': 'Phân tích bằng AI', 'admin.addons.catalog.llm_parsing.description': 'Đọc các đặt chỗ mà bộ phân tích tích hợp không hiểu, bằng mô hình AI bạn chọn', 'admin.addons.enabled': 'Đã bật', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 98455514b..7e3ff8461 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -603,6 +603,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': '從共享的 Naver 地圖清單匯入地點', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': '從你的 AirTrail 執行個體同步航班', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + '從各位使用者自行連接的 Dawarich 執行個體讀取造訪紀錄與記錄的路線', 'admin.addons.catalog.llm_parsing.name': 'AI 解析', 'admin.addons.catalog.llm_parsing.description': '用你選擇的 AI 模型讀取內建解析器無法識別的訂位', 'admin.addons.enabled': '已啟用', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 85821b276..631a01e34 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -563,6 +563,9 @@ const admin: TranslationStrings = { 'admin.addons.catalog.naver_list_import.description': '从共享的 Naver 地图列表导入地点', 'admin.addons.catalog.airtrail.name': 'AirTrail', 'admin.addons.catalog.airtrail.description': '从你的 AirTrail 实例同步航班', + 'admin.addons.catalog.dawarich.name': 'Dawarich', + 'admin.addons.catalog.dawarich.description': + '从每位使用者自行连接的 Dawarich 实例读取到访记录和记录的路线', 'admin.addons.catalog.llm_parsing.name': 'AI 解析', 'admin.addons.catalog.llm_parsing.description': '用你选择的 AI 模型读取内置解析器无法识别的预订', 'admin.addons.enabled': '已启用', From 5678eed55c168130297367547a1c087e019e7052 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 23:35:30 +0200 Subject: [PATCH 4/7] fix(client): use TREK's own controls for tooltips and journey dates Three small things noticed while working nearby, none of them Dawarich: The map's layer switcher showed the browser's own tooltip while every control beside it showed TREK's, so hovering along the row produced two different boxes in two different styles. The journey entry editor mixed TREK's date picker with a native time input, and the date, which is the longer of the two, had the narrower column: "12. Sept. 2026" wrapped while the clock sat half empty. The row is split in the date's favour now and the picker is disabled together with the rest of the form instead of staying live behind it. On the phone's journey, the dock's "+" now turns into an upload button while the Gallery tab is open, which is what that tab's one big action actually is. --- .../Journey/JourneyDetailPageEntryEditor.tsx | 12 ++++-- .../src/components/Map/MapLayerSwitcher.tsx | 42 +++++++++++-------- .../shared/CustomDateTimePicker.tsx | 7 +++- client/src/mobile/components/MBottomNav.tsx | 22 ++++++++-- .../screens/journey/MJourneyEntrySheet.tsx | 23 +++++----- client/src/store/journeyStore.ts | 11 +++++ .../journey/MJourneyEntrySheet.test.tsx | 28 +++++++++++-- 7 files changed, 101 insertions(+), 44 deletions(-) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index a7d3e4668..27f4108d5 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -606,13 +606,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, -
+ {/* The date needs the room, not the clock: a long localized date + ("12. Sept. 2026") wrapped onto a second line while the time field + sat half empty beside it. The row is split in the date's favour and + the location, which is a free-text search, gives some back. */} +
{/* Time sat in state and went to the server, it just had no input here — so a draft's auto-stamped clock time showed up in the timeline, the map, the PDF and the public share, and the desktop had no way to correct it (#1614). */} - {/* 136px, not 104: the custom picker adds a clock button and, in 12h, - shows "2:30 PM" where the native input showed a fixed-width HH:MM. */} -
+ {/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no + more — the date column is what needed the space back. */} +
diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 2c7db30b8..f9700d8dd 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,5 +1,6 @@ import { Map as MapIcon, Satellite } from 'lucide-react' import { useTranslation } from '../../i18n' +import { Tooltip } from '../shared/Tooltip' import { MAP_CONTROL_SHADOW } from './mapControlShadow' export type BaseLayer = 'default' | 'satellite' @@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo WebkitBackdropFilter: 'blur(20px) saturate(180%)', boxShadow: MAP_CONTROL_SHADOW, }}> - + {/* Same tooltip as its neighbours on the map, for the same reasons — see + TripRouteOverviewPill. To the right, though: this one sits at the + bottom left of the map, where a tooltip to the left would land on the + day sidebar and one below would land off the screen. */} + + +
) } diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index 3bdb3441e..69c09ef07 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -21,6 +21,8 @@ interface CustomDatePickerProps { style?: React.CSSProperties; compact?: boolean; borderless?: boolean; + /** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */ + disabled?: boolean; // Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are // disabled in the calendar and rejected on manual entry. min?: string; @@ -34,6 +36,7 @@ export function CustomDatePicker({ style = {}, compact = false, borderless = false, + disabled = false, min, max, }: CustomDatePickerProps) { @@ -299,6 +302,7 @@ export function CustomDatePicker({