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.
This commit is contained in:
Maurice
2026-09-12 23:34:54 +02:00
parent dad1316172
commit 218d8baf0a
55 changed files with 7469 additions and 29 deletions
+16
View File
@@ -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) {
+2
View File
@@ -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,
+111
View File
@@ -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) {
+1
View File
@@ -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 },
];
+5 -3
View File
@@ -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<{
+2 -1
View File
@@ -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
+57 -14
View File
@@ -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;
});
}
@@ -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);
+6 -2
View File
@@ -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!);
}
+7
View File
@@ -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) {}
@@ -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<SuggestionJoinRow>(
`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<SuggestionJoinRow>(
`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<SuggestionRow>(
'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<PlacesService['create']>[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<DawarichBucketScan> {
const creds = this.dawarich.getCredentials(userId);
if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
const items = this.db.all<BucketRow>(
`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<DawarichBucketMatch['match']> {
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<DawarichAtlasSuggestions> {
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;
}
@@ -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<void> {
try {
await this.sync.runSync();
} catch (err: unknown) {
logError(`Dawarich sync tick failed: ${err instanceof Error ? err.message : err}`);
}
}
}
@@ -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<number>();
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<void> {
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<DawarichSyncOutcome> {
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<DawarichSyncOutcome> {
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<TripRow>(
`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<string>();
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<SuggestionRow>(
'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<string>,
): 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;
}
@@ -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<string, { at: number; value: DawarichTrack }>();
/**
* 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<DawarichTrack | null> {
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<DawarichTrack> {
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<DawarichTrack> {
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,
};
}
@@ -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 <key>` (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(/(?<!\/)\/+$/, '')
.replace(/\/api\/v1$/i, '')
.replace(/\/api$/i, '');
return `${origin}/api/v1`;
}
/** ISO-8601 in UTC. `Time.zone.parse` on the Dawarich side reads this unambiguously. */
export function isoUtc(value: Date): string {
return value.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/**
* Unix seconds as a bare digit string.
*
* The points and visited-cities endpoints run their input through
* `safe_timestamp`, which falls back to **the current time** for anything it
* cannot parse — silently. A digit string takes the one branch that cannot
* misread, so a formatting slip becomes an empty result instead of a plausible
* wrong one.
*/
export function unixSeconds(value: Date): string {
return String(Math.floor(value.getTime() / 1000));
}
function classify(status: number): DawarichErrorCode {
if (status === 401) return 'unauthorized';
if (status === 403) return 'forbidden';
if (status === 404) return 'not_found';
if (status === 429) return 'rate_limited';
return 'server_error';
}
@Injectable()
export class DawarichClient {
/**
* One authenticated GET, size-capped and parsed.
*
* Returns the headers alongside the body because two things TREK needs are
* only in them: the version banner and the page count.
*/
private async get<T>(
creds: DawarichCreds,
path: string,
query: Record<string, string | undefined>,
): 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<T>(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<DawarichProbe> {
const { headers } = await this.get<unknown>(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<DawarichVisitRaw[]>(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<DawarichSlimPoint[]>(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<DawarichVisitedCountry[]> {
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<DawarichLocationVisit[]> {
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 : [];
}
}
@@ -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<T>(run: () => T): T {
try {
return run();
} catch (err) {
throw toHttp(err);
}
}
private async guardAsync<T>(run: () => Promise<T>): Promise<T> {
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;
}
@@ -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) {}
Binary file not shown.
@@ -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<T>(action: () => T) {
try {
return ok(action() as object);
} catch (err) {
if (err instanceof AcceptError) return errorResult(err.message);
throw err;
}
}
}
@@ -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 {}
@@ -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<ConnRow>(
`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<DawarichStatus> {
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<DawarichCapabilities> {
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<T>(run: () => Promise<T>): 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<number> {
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<DawarichCapabilities>;
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;
}
}
+11
View File
@@ -3,6 +3,7 @@
// (`(req as AuthRequest).user`, `getRequest<Request & { user?: User }>()`) 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;
}
}
+345
View File
@@ -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<ReturnType<typeof build>>;
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' });
});
});
+4
View File
@@ -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',
+13 -3
View File
@@ -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)', () => {
@@ -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<unknown>): Promise<AcceptError> {
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');
});
});
@@ -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<DawarichCapabilities> => 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<DawarichVisitRaw> & { 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();
});
});
@@ -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<string>;
}
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<string, string>;
}): 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<unknown>): Promise<DawarichError> {
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<string, string> | 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<string> }) => {
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: '<!doctype html><title>Sign in</title>' }));
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([]);
});
});
@@ -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<DawarichService>;
sync?: Partial<DawarichSyncService>;
suggestions?: Partial<DawarichSuggestionsService>;
tracks?: Partial<DawarichTracksService>;
}
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<string, string> = {}, 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, '<html>login</html>'),
);
expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
.toEqual({ status: 502, body: { error: 'Unexpected response', code: 'invalid_response', detail: '<html>login</html>' } });
});
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);
});
});
@@ -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> = {}): DawarichVisitRaw => ({ ...BASE_VISIT, ...over });
/** A fixture whose shape is deliberately wrong — the payload is a stranger's JSON. */
const malformedVisit = (over: Record<string, unknown>): 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<Array<[number, number]>>): 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);
});
});
@@ -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<ReturnType<AtlasService['getTravelStats']>> = {}) => ({
countries: ['JP', 'IT'],
+1 -1
View File
@@ -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 },
+3
View File
@@ -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': 'مفعّل',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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é',
+3
View File
@@ -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': 'Ενεργοποιημένο',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': '有効',
+3
View File
@@ -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': '활성화됨',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Включено',
+3
View File
@@ -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',
+3
View File
@@ -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ş',
+3
View File
@@ -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': 'Увімкнено',
+3
View File
@@ -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',
+3
View File
@@ -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': '已啟用',
+3
View File
@@ -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': '已启用',