refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories

This commit is contained in:
jubnl
2026-09-23 23:55:52 +02:00
parent 3a4646a7bb
commit 1d72c6909b
21 changed files with 2068 additions and 469 deletions
@@ -40,6 +40,10 @@ interface BucketListInsertKyselyDB {
interface BucketListWriteKyselyDB {
bucket_list: { id: number; user_id: number; name: string; notes: string | null; lat: number | null; lng: number | null; country_code: string | null; target_date: string | null };
}
/** The narrow `bucket_list` shape Plan 3h Task 3's visited-tick writes (DWS9/13/15) need. */
interface BucketListVisitKyselyDB {
bucket_list: { id: number; user_id: number; visited_at: string | null; visited_source: string | null };
}
/** The six columns `findDuplicate`'s #1898 dedup identity compares (`AT31`). */
export interface BucketListIdentity {
@@ -69,6 +73,10 @@ export class BucketListRepository extends TrekRepository<BucketList> {
return this.kysely<BucketListWriteKyselyDB>();
}
private visitDb() {
return this.kysely<BucketListVisitKyselyDB>();
}
/** AT30 (`bucketList`) — `SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC`. */
async listForUser(userId: number): Promise<BucketListRow[]> {
return await this.readDb().selectFrom('bucket_list').selectAll().where('user_id', '=', userId).orderBy('created_at', 'desc').execute();
@@ -191,4 +199,101 @@ export class BucketListRepository extends TrekRepository<BucketList> {
async deleteForUser(id: number | string, userId: number): Promise<void> {
await this.writeDb().deleteFrom('bucket_list').where('id', '=', id as number).where('user_id', '=', userId).execute();
}
// ---------------------------------------------------------------------------
// Plan 3h Task 3 (`DawarichSuggestionsService`/`DawarichSyncService`) —
// additive. Cross-domain reads/writes from the Dawarich integration (3h)
// into this 3f-owned table.
// ---------------------------------------------------------------------------
/**
* DWS9 — `acceptAsBucketTick`: `UPDATE bucket_list SET visited_at = ?,
* visited_source = 'dawarich' WHERE id = ? AND user_id = ?`,
* unconditional (DWS8's own `findForUser`-shaped guard read already
* confirmed the row exists and belongs to the caller before this write).
*/
async markVisited(id: number, userId: number, visitedAt: string): Promise<void> {
await this.visitDb()
.updateTable('bucket_list')
.set({ visited_at: visitedAt, visited_source: 'dawarich' })
.where('id', '=', id)
.where('user_id', '=', userId)
.execute();
}
/**
* DWS12 — `scanBucketList`: `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`. The boolean-expression `ORDER BY` key is
* portable SQL — no dialect helper needed.
*/
async listForScan(userId: number): Promise<{ id: number; name: string; lat: number | null; lng: number | null; visited_at: string | null }[]> {
return await this.readDb()
.selectFrom('bucket_list')
.select(['id', 'name', 'lat', 'lng', 'visited_at'])
.where('user_id', '=', userId)
.orderBy((eb) => eb('visited_at', 'is not', null))
.orderBy('created_at', 'desc')
.orderBy('id', 'desc')
.execute();
}
/**
* DWS13 — `confirmBucketVisits` (looped inside the SERVICE's own
* `uow.transactional` block): `UPDATE bucket_list SET visited_at = ?,
* visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at
* IS NULL` — conditional, won't clobber an existing tick. Returns the
* affected-row count so the caller can total how many were actually
* updated.
*/
async markVisitedIfUnset(id: number, userId: number, visitedAt: string): Promise<number> {
const result = await this.visitDb()
.updateTable('bucket_list')
.set({ visited_at: visitedAt, visited_source: 'dawarich' })
.where('id', '=', id)
.where('user_id', '=', userId)
.where('visited_at', 'is', null)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0);
}
/** DWS15 — `clearBucketVisit`: `UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?`. Returns whether a row was actually touched. */
async clearVisited(id: number, userId: number): Promise<boolean> {
const result = await this.visitDb()
.updateTable('bucket_list')
.set({ visited_at: null, visited_source: null })
.where('id', '=', id)
.where('user_id', '=', userId)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* DSY10 — `dawarich-sync.service.ts::matchBucketList`'s bounding-box
* pre-filter: `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 ?`. A rough box only — the real distance test (haversine)
* stays in the SERVICE, in JS. Typed `lat`/`lng` nullable to match the
* column's own schema type; the `IS NOT NULL` conditions guarantee they
* are never actually null in a returned row.
*/
async listInBoundingBox(
userId: number,
latMin: number,
latMax: number,
lngMin: number,
lngMax: number,
): Promise<{ id: number; lat: number | null; lng: number | null }[]> {
return await this.readDb()
.selectFrom('bucket_list')
.select(['id', 'lat', 'lng'])
.where('user_id', '=', userId)
.where('lat', 'is not', null)
.where('lng', 'is not', null)
.where('lat', '>=', latMin)
.where('lat', '<=', latMax)
.where('lng', '>=', lngMin)
.where('lng', '<=', lngMax)
.execute();
}
}
@@ -1,4 +1,201 @@
import { currentTimestamp } from '../dialect/sql-functions';
import type { DawarichConnections } from '../entities/DawarichConnections.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class DawarichConnectionsRepository extends TrekRepository<DawarichConnections> {}
/**
* `dawarich_connections` — every scalar column, for {@link AssertRowKeys}'s
* pin only. No method below returns this exact shape: DWC1's `findRow`
* covers 9 of the 11 (the legacy `readRow` statement never selected
* `created_at`/`updated_at`), and nothing else reads a full row.
*/
interface DawarichConnectionsAllColumns {
user_id: number;
url: string | null;
api_key: string | null;
allow_insecure_tls: number;
sync_enabled: number;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
capabilities: string | null;
created_at: string | null;
updated_at: string | null;
}
const _dawarichConnectionsAllColumns: AssertRowKeys<DawarichConnectionsAllColumns, DawarichConnections> = true;
/**
* DWC1's own 9-column read (`readRow`'s legacy statement text). **R7**:
* `api_key` is the still-encrypted TEXT value, passed through untouched —
* `maybe_encrypt_api_key`/`decrypt_api_key` are called from `DawarichService`
* only, never from this repository.
*/
export interface DawarichConnectionRow {
user_id: number;
url: string | null;
api_key: string | null;
allow_insecure_tls: number;
sync_enabled: number;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
capabilities: string | null;
}
/**
* `dawarich_connections` — one row per user, `user` (mirrored as `user_id`)
* the entity's own primary key (`[PrimaryKeyProp]?: 'user'`, same shape as
* `VacayUserSettingsRepository`'s own table). Every dawarich file depends on
* this repository's `getCredentials`/`listSyncableUserIds`/
* `recordSyncResult` (via `DawarichService`) — convert this one first.
*/
export class DawarichConnectionsRepository extends TrekRepository<DawarichConnections> {
/**
* DWC1 — `readRow`: `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 = ?`.
* `mapResults: false` (matching `TripsRepository.findAccessible`'s own
* PK-is-FK read) so the raw driver row is returned as-is — `c.user` is the
* relation property; SQL still names the physical `user_id` column.
*/
async findRow(userId: number): Promise<DawarichConnectionRow | null> {
const row = await this.qb('c')
.select(['c.user', 'c.url', 'c.api_key', 'c.allow_insecure_tls', 'c.sync_enabled', 'c.last_sync_at', 'c.last_sync_state', 'c.last_sync_error', 'c.capabilities'])
.where({ user: userId })
.execute<DawarichConnectionRow | undefined>('get', false);
return row ?? null;
}
/**
* DWC2 — `listSyncableUserIds`: `SELECT user_id FROM dawarich_connections
* WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS
* NOT NULL`. **NON-HTTP ENTRYPOINT SOURCE** — the cron's user list. Three
* `.andWhere` calls, not one combined filter object: `url`'s two
* conditions (`IS NOT NULL` and `<> ''`) cannot both live under one
* `{ url: {...} }` key.
*/
async listSyncableUserIds(): Promise<number[]> {
const rows = await this.qb('c')
.select(['c.user'])
.where({ sync_enabled: 1 })
.andWhere({ url: { $ne: null } })
.andWhere({ url: { $ne: '' } })
.andWhere({ api_key: { $ne: null } })
.execute<{ user_id: number }[]>('all', false);
return rows.map((r) => r.user_id);
}
/** DWC3 — `saveSettings`'s previous-host read: `SELECT url FROM dawarich_connections WHERE user_id = ?`. */
async getUrl(userId: number): Promise<string | null> {
const row = await this.qb('c')
.select(['c.url'])
.where({ user: userId })
.execute<{ url: string | null } | undefined>('get', false);
return row?.url ?? null;
}
/**
* DWC4 — `saveSettings`'s upsert: `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`.
* Single-column-PK upsert, `onConflictFields: ['user']`.
* `onConflictMergeFields` narrowed to exactly the four columns the legacy
* statement's own `SET` list touches (`OauthConsentsRepository.upsertGrant`'s
* precedent for the unconditional `updated_at` stamp;
* `VacayUserYearsRepository.upsertCarriedOver`'s precedent for narrowing
* the merge set) — `api_key`/`capabilities`/`last_sync_*` are untouched on
* conflict and default to NULL/`'never'` on a genuine INSERT, matching the
* legacy statement's own column list exactly.
*/
async upsertConnection(userId: number, data: { url: string | null; allowInsecureTls: boolean; syncEnabled: boolean }): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.upsert(
{
user: userId,
url: data.url,
allow_insecure_tls: data.allowInsecureTls ? 1 : 0,
sync_enabled: data.syncEnabled ? 1 : 0,
updated_at: currentTimestamp(platform),
},
{ onConflictFields: ['user'], onConflictAction: 'merge', onConflictMergeFields: ['url', 'allow_insecure_tls', 'sync_enabled', 'updated_at'] },
);
}
/**
* DWC5/DWC7 — `saveSettings`'s key write/scrub, one statement text:
* `UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?`.
* **SECURITY**: `apiKey` is the already-encrypted TEXT (or `null` to
* scrub), passed through untouched (R7).
*/
async setApiKey(userId: number, apiKey: string | null): Promise<void> {
await this.nativeUpdate({ user: userId }, { api_key: apiKey });
}
/**
* DWC6 — `saveSettings`'s host-moved reset: `UPDATE dawarich_connections
* SET capabilities = NULL, last_sync_state = 'never', last_sync_error =
* NULL, last_sync_at = NULL WHERE user_id = ?`.
*/
async resetSyncState(userId: number): Promise<void> {
await this.nativeUpdate({ user: userId }, { capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
}
/**
* DWC8 — `saveSettings`'s address-cleared reset: `UPDATE
* dawarich_connections SET api_key = NULL, capabilities = NULL,
* last_sync_state = 'never', last_sync_error = NULL, last_sync_at = NULL
* WHERE user_id = ?` — {@link resetSyncState}'s four columns plus
* `api_key`, one statement (never two calls: the legacy text is a single
* UPDATE with five `SET` clauses).
*/
async clearForRemovedUrl(userId: number): Promise<void> {
await this.nativeUpdate({ user: userId }, { api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
}
/**
* DWC9 — `disconnect`: `DELETE FROM dawarich_connections WHERE user_id =
* ?`. **PARITY**: does not cascade to `dawarich_visit_suggestions` —
* deliberate (the entity carries no relation from suggestions back to a
* connection at all, so there is nothing for a migration-level cascade to
* even attach to).
*/
async disconnect(userId: number): Promise<void> {
await this.nativeDelete({ user: userId });
}
/** DWC10 — `storeCapabilities`: `UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`. */
async storeCapabilities(userId: number, capabilitiesJson: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ user: userId }, { capabilities: capabilitiesJson, updated_at: currentTimestamp(platform) });
}
/**
* DWC11 — `recordSyncResult`: `UPDATE dawarich_connections SET
* last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at =
* CURRENT_TIMESTAMP WHERE user_id = ?`. **NON-HTTP ENTRYPOINT** — called
* after every cron sync.
*/
async recordSyncResult(userId: number, data: { lastSyncAt: string; state: string; error: string | null }): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ user: userId },
{ last_sync_at: data.lastSyncAt, last_sync_state: data.state, last_sync_error: data.error, updated_at: currentTimestamp(platform) },
);
}
/**
* DSY1 — `dawarich-sync.service.ts#syncUser`'s in-flight-branch read, a
* narrower dup shape of DWC1: `SELECT last_sync_state FROM
* dawarich_connections WHERE user_id = ?`. Lives here (not on
* `DawarichVisitSuggestionsRepository`) because it reads this table.
*/
async getLastSyncState(userId: number): Promise<string | null> {
const row = await this.qb('c')
.select(['c.last_sync_state'])
.where({ user: userId })
.execute<{ last_sync_state: string } | undefined>('get', false);
return row?.last_sync_state ?? null;
}
}
@@ -1,4 +1,399 @@
import { coalesceParam, columnRef } from '../dialect/sql-functions';
import type { DawarichVisitSuggestions } from '../entities/DawarichVisitSuggestions.entity';
import { type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class DawarichVisitSuggestionsRepository extends TrekRepository<DawarichVisitSuggestions> {}
/** `dawarich_visit_suggestions` — every scalar column, DWS6/DSY3's `SELECT *` shape. */
export 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;
}
const _suggestionRowKeys: AssertRowKeys<SuggestionRow, DawarichVisitSuggestions> = true;
/** DWS1/DWS3's joined shape — `SuggestionRow` plus the two `LEFT JOIN`-derived columns. */
export interface SuggestionJoinRow extends SuggestionRow {
trip_title: string | null;
matched_bucket_name: string | null;
}
/** The narrow `dawarich_visit_suggestions`/`trips`/`bucket_list` shape DWS1/DWS3's join needs. */
interface SuggestionJoinKyselyDB {
dawarich_visit_suggestions: SuggestionRow;
trips: { id: number; title: string | null };
bucket_list: { id: number; name: string };
}
/** The narrow `dawarich_visit_suggestions` shape a bare `SELECT *` read needs (DWS6/DSY3). */
interface SuggestionAllKyselyDB {
dawarich_visit_suggestions: SuggestionRow;
}
/**
* The narrow `dawarich_visit_suggestions`/`journey_entries` shape DWS2's
* `reopenOrphaned` needs. `journey_entries` is 3g's table, read cross-domain
* over `this.kysely()` — the same "a repository may query any table"
* convention `JourneyEntriesRepository`'s own cross-domain reads
* (`listStatsTrips`/`listSuggestedTrips`) already establish — rather than a
* `JourneyEntriesRepository.existsById` call: 3g's Task 4 (the survivors
* task that would add that method) had not landed at the time this task
* ran, and appending to a Journey* repository is outside this task's file
* set (owned by that concurrent implementer). A single self-contained
* `NOT EXISTS` subquery inside the SAME `UPDATE` also preserves the legacy
* statement's one-round-trip shape more faithfully than a JS-side
* per-row `existsById` loop would have.
*/
interface ReopenOrphanedKyselyDB {
dawarich_visit_suggestions: SuggestionRow;
journey_entries: { id: number };
}
/** The narrow shape DSY7's window-candidates read needs. */
interface CandidateRow {
id: number;
source_visit_id: string;
state: string;
}
/** The narrow shape DSY11's wish-holders read needs. */
interface HolderRow {
id: number;
source_visit_id: string;
lat: number | null;
lng: number | null;
duration_minutes: number;
}
/**
* `dawarich_visit_suggestions` — one row per Dawarich stay TREK has synced
* in, reviewable by the user it belongs to. Depends on
* `DawarichConnectionsRepository` (via `DawarichService`) for credentials;
* reaches `journey_entries` (3g), `places` (3c, additive `PlacesRepository
* .setSource`) and `bucket_list` (3f, additive `BucketListRepository`
* methods) cross-domain.
*/
export class DawarichVisitSuggestionsRepository extends TrekRepository<DawarichVisitSuggestions> {
private joinedQuery() {
return this.kysely<SuggestionJoinKyselyDB>()
.selectFrom('dawarich_visit_suggestions as s')
.leftJoin('trips as t', 't.id', 's.trip_id')
.leftJoin('bucket_list as b', 'b.id', 's.matched_bucket_list_item_id')
.selectAll('s')
.select(['t.title as trip_title', 'b.name as matched_bucket_name']);
}
/**
* DWS1 — `list`: `SELECT s.*, t.title AS trip_title, b.name AS
* matched_bucket_name FROM dawarich_visit_suggestions s LEFT JOIN trips t
* LEFT JOIN bucket_list b WHERE (dynamic: s.user_id=? [+ s.trip_id=?] [+
* s.state=?]) ORDER BY s.started_at DESC, s.id DESC`. **SECURITY**:
* `user_id` is scoped in SQL, never filtered after — these rows are
* someone's location history.
*/
async list(userId: number, filter: { tripId?: number; state?: string }): Promise<SuggestionJoinRow[]> {
let q = this.joinedQuery().where('s.user_id', '=', userId);
if (filter.tripId !== undefined) q = q.where('s.trip_id', '=', filter.tripId);
if (filter.state) q = q.where('s.state', '=', filter.state);
const rows = await q.orderBy('s.started_at', 'desc').orderBy('s.id', 'desc').execute();
return rows as SuggestionJoinRow[];
}
/**
* DWS2 — `reopenOrphaned`: put back into review every acceptance whose
* result no longer exists. Returns the affected-row count (unused by the
* caller today — `list`'s own re-select after this write is what the
* service reads — but kept for parity with every other write method here).
*/
async reopenOrphaned(userId: number): Promise<number> {
const result = await this.kysely<ReopenOrphanedKyselyDB>()
.updateTable('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', '=', userId)
.where('state', '=', 'accepted')
.where((eb) =>
eb.or([
eb.and([eb('target', '=', 'place'), eb('accepted_place_id', 'is', null)]),
eb.and([eb('target', '=', 'bucket_list'), eb('accepted_bucket_list_item_id', 'is', null)]),
eb.and([
eb('target', '=', 'journal'),
eb.or([
eb('accepted_journal_entry_id', 'is', null),
eb.not(
eb.exists(
eb
.selectFrom('journey_entries')
.select('id')
.whereRef('journey_entries.id', '=', 'dawarich_visit_suggestions.accepted_journal_entry_id'),
),
),
]),
]),
]),
)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0);
}
/** DWS3 — `getOne`: single-row variant of {@link list}'s join. */
async getOne(userId: number, id: number): Promise<SuggestionJoinRow | null> {
const row = await this.joinedQuery().where('s.id', '=', id).where('s.user_id', '=', userId).executeTakeFirst();
return (row as SuggestionJoinRow) ?? null;
}
/** DWS4 — `setState`'s guard read: `SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?`. */
async findStateForUser(id: number, userId: number): Promise<{ id: number; state: string } | null> {
const row = await this.qb('s')
.select(['s.id', 's.state'])
.where({ id, user: userId })
.execute<{ id: number; state: string } | undefined>('get', false);
return row ?? null;
}
/** DWS5 — `setState`: `UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?`. */
async setState(id: number, state: string): Promise<void> {
await this.nativeUpdate({ id }, { state });
}
/**
* DWS6/DSY3 — `SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND
* user_id = ?` (`accept`'s guard) and `WHERE user_id = ? AND
* source_visit_id = ?` (`syncTripWindow`'s existing-row read), one shape
* with a different WHERE — {@link findForUser}/{@link findByUserAndVisit}.
* Kysely `selectAll()` rather than a MikroORM `qb` read: every
* `accepted_*`/`trip_id`/`matched_bucket_list_item_id` column is a
* `persist(false)` shadow FK on this entity, and a plain `SELECT *` over
* the physical table sidesteps that trap entirely rather than needing a
* `columnRef`/full-entity-load workaround for each one.
*/
async findForUser(id: number, userId: number): Promise<SuggestionRow | null> {
const row = await this.kysely<SuggestionAllKyselyDB>()
.selectFrom('dawarich_visit_suggestions')
.selectAll()
.where('id', '=', id)
.where('user_id', '=', userId)
.executeTakeFirst();
return (row as SuggestionRow) ?? null;
}
/** DSY3 — `syncTripWindow`'s existing-row read: `SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?`. Implies `UNIQUE(user_id, source_visit_id)` (the entity's own `uniques`). */
async findByUserAndVisit(userId: number, sourceVisitId: string): Promise<SuggestionRow | null> {
const row = await this.kysely<SuggestionAllKyselyDB>()
.selectFrom('dawarich_visit_suggestions')
.selectAll()
.where('user_id', '=', userId)
.where('source_visit_id', '=', sourceVisitId)
.executeTakeFirst();
return (row as SuggestionRow) ?? null;
}
/** DSY4 — `syncTripWindow`'s not-existing branch: the 17-column `INSERT`, `state` fixed to `'new'`. Returns the new row's id. */
async insertVisit(data: {
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;
source_hash: string;
first_seen_at: string;
last_seen_at: string;
}): Promise<number> {
return await this.insert({
user: data.user_id,
source_visit_id: data.source_visit_id,
trip: data.trip_id,
name: data.name,
lat: data.lat,
lng: data.lng,
started_at: data.started_at,
ended_at: data.ended_at,
duration_minutes: data.duration_minutes,
local_date: data.local_date,
source_status: data.source_status,
confidence: data.confidence,
confidence_band: data.confidence_band,
country_code: data.country_code,
state: 'new',
source_hash: data.source_hash,
first_seen_at: data.first_seen_at,
last_seen_at: data.last_seen_at,
});
}
/** DSY5 — `syncTripWindow`'s existing, `state='new'` branch: the full 15-column overwrite (a stay nobody has acted on yet). */
async updateNewVisit(
id: number,
data: {
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;
source_hash: string;
trip_id: number | null;
last_seen_at: string;
},
): Promise<void> {
await this.nativeUpdate(
{ id },
{
name: data.name,
lat: data.lat,
lng: data.lng,
started_at: data.started_at,
ended_at: data.ended_at,
duration_minutes: data.duration_minutes,
local_date: data.local_date,
source_status: data.source_status,
confidence: data.confidence,
confidence_band: data.confidence_band,
country_code: data.country_code,
source_hash: data.source_hash,
source_missing_at: null,
trip: data.trip_id,
last_seen_at: data.last_seen_at,
},
);
}
/**
* DSY6 — `syncTripWindow`'s existing, accepted/dismissed branch: `UPDATE
* dawarich_visit_suggestions SET source_hash = ?, source_missing_at =
* NULL, last_seen_at = ? WHERE id = ?`. **PARITY**: "nothing the user can
* see is rewritten".
*/
async refreshHash(id: number, sourceHash: string, lastSeenAt: string): Promise<void> {
await this.nativeUpdate({ id }, { source_hash: sourceHash, source_missing_at: null, last_seen_at: lastSeenAt });
}
/** DSY7 — `flagMissing`'s candidates read: `SELECT id, source_visit_id, state FROM dawarich_visit_suggestions WHERE user_id = ? AND trip_id = ? AND local_date >= ? AND local_date <= ?`. **PARITY-CRITICAL**: `local_date` string bounding — see the SERVICE's own doc comment on why. */
async listCandidatesForWindow(userId: number, tripId: number, from: string, to: string): Promise<CandidateRow[]> {
return await this.qb('s')
.select(['s.id', 's.source_visit_id', 's.state'])
.where({ user: userId, trip: tripId, local_date: { $gte: from, $lte: to } })
.execute<CandidateRow[]>('all', false);
}
/** DSY8 — `flagMissing`'s delete branch (only unseen `'new'`-state rows): `DELETE FROM dawarich_visit_suggestions WHERE id = ?`. */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/**
* DSY9 — `flagMissing`'s stamp branch: `UPDATE dawarich_visit_suggestions
* SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?` —
* preserves the FIRST-missing timestamp. `coalesceParam` as an UPDATE-SET
* value (its own documented shape), not a filter key.
*/
async stampMissingIfUnset(id: number, stamp: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { source_missing_at: coalesceParam(platform, 'source_missing_at', stamp) });
}
/**
* DWS11 — `markAccepted`: `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=?`. **`accepted_hash = source_hash`
* is a self-column copy** — `columnRef(platform, 'source_hash')`, a
* column-REFERENCE expression read at UPDATE time, never a bound
* parameter: binding the row's CURRENT `source_hash` as a literal would
* freeze it at the moment this method was CALLED, not at the moment the
* UPDATE actually commits — wrong the instant a concurrent sync run
* changes `source_hash` between the two. `acceptedPlace`/
* `acceptedBucketListItem` (relation properties, not their `persist(false)`
* `_id` mirrors) for the two FK writes; `accepted_journal_entry_id` is a
* plain persisted integer (journey entries carry no FK back from this
* table) written directly.
*/
async markAccepted(
id: number,
data: { target: string; placeId: number | null; journalEntryId: number | null; bucketItemId: number | null },
): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate(
{ id },
{
state: 'accepted',
target: data.target,
acceptedPlace: data.placeId,
accepted_journal_entry_id: data.journalEntryId,
acceptedBucketListItem: data.bucketItemId,
accepted_hash: columnRef(platform, 'source_hash'),
},
);
}
/** DSY11 — `claimWish`'s holders read: `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 <> ?`. */
async listHoldersOfWish(userId: number, wishId: number, excludeSourceVisitId: string): Promise<HolderRow[]> {
return await this.qb('s')
.select(['s.id', 's.source_visit_id', 's.lat', 's.lng', 's.duration_minutes'])
.where({ user: userId, matchedBucketListItem: wishId, source_visit_id: { $ne: excludeSourceVisitId } })
.execute<HolderRow[]>('all', false);
}
/** DSY12 — `claimWish` (same TX as {@link assignWishHolder}, order matters): `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?` — clears every previous holder. */
async clearWishHolders(userId: number, wishId: number): Promise<void> {
await this.nativeUpdate({ user: userId, matchedBucketListItem: wishId }, { matchedBucketListItem: null });
}
/** DSY13 — `claimWish`: `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?` — assigns the winner. Called AFTER {@link clearWishHolders} in the same transaction, never before. */
async assignWishHolder(userId: number, sourceVisitId: string, wishId: number): Promise<void> {
await this.nativeUpdate({ user: userId, source_visit_id: sourceVisitId }, { matchedBucketListItem: wishId });
}
/** DWS14 — `matchedStayStart`: `SELECT started_at FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? ORDER BY started_at DESC LIMIT 1`. */
async matchedStayStart(userId: number, itemId: number): Promise<string | null> {
const row = await this.qb('s')
.select(['s.started_at'])
.where({ user: userId, matchedBucketListItem: itemId })
.orderBy({ started_at: 'desc' })
.limit(1)
.execute<{ started_at: string } | undefined>('get', false);
return row?.started_at ?? null;
}
}
@@ -988,6 +988,200 @@ export class PlacesRepository extends TrekRepository<Places> {
.where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)]))
.execute();
}
// ---------------------------------------------------------------------------
// Plan 3h Task 3 (`DawarichSuggestionsService`) — additive.
// ---------------------------------------------------------------------------
/**
* DWS7 (`dawarich-suggestions.service.ts::acceptAsPlace`) — `UPDATE places
* SET source = 'dawarich' WHERE id = ?`. No `updated_at` stamp — the
* legacy statement never wrote one for this column (same "no stamp"
* shape {@link setRouteColor} above already documents for a different
* column). Cross-domain write from the Dawarich integration (3h) into
* this 3c-owned table.
*/
async setSource(id: number, source: string): Promise<void> {
await this.nativeUpdate({ id }, { source });
}
// ---------------------------------------------------------------------------
// Plan 3h Task 2 (`CollectionsService`) — additive, cross-domain. Flagged in
// the task's own report per its brief: these touch `places`, a 3c-owned
// table, from the collections cluster's own copy/import surface.
// ---------------------------------------------------------------------------
/**
* CL45 (`importablePlaces`) — the trip's places as offered to the
* collection import dialog, one row per place with two correlated scalar
* subqueries resolving the EARLIEST day it is assigned to (a place can
* sit on several days): `SELECT p.id AS place_id, p.name, p.address,
* p.lat, p.lng, p.category_id, p.image_url, p.google_place_id,
* p.google_ftid, p.osm_id, (SELECT MIN(d.day_number) FROM day_assignments
* da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND
* d.trip_id=p.trip_id) AS day_number, (SELECT d.date FROM day_assignments
* da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND
* d.trip_id=p.trip_id ORDER BY d.day_number ASC LIMIT 1) AS date FROM
* places p WHERE p.trip_id=? ORDER BY p.name COLLATE NOCASE`. Kysely — a
* correlated scalar subquery has no MikroORM QueryBuilder expression
* (`JourneysRepository.listForUser`'s own `eb.selectFrom(...).whereRef
* (...).as(...)` precedent, the closest analog: five correlated
* subqueries over a `LEFT JOIN`). `ORDER BY ... COLLATE NOCASE` is
* Kysely's own native `OrderByItemBuilder.collate('nocase')` — a portable
* Kysely API, not a raw SQLite fragment, so it needs no
* `sql-functions.ts` dialect wrapper (rule 5 covers dialect FUNCTIONS,
* not Kysely's own builder surface).
*/
async listImportable(tripId: number): Promise<ImportablePlaceRow[]> {
return await this.kysely<ImportablePlacesKyselyDB>()
.selectFrom('places as p')
.select((eb) => [
'p.id as place_id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.category_id', 'p.image_url',
'p.google_place_id', 'p.google_ftid', 'p.osm_id',
eb
.selectFrom('day_assignments as da')
.innerJoin('days as d', 'd.id', 'da.day_id')
.select((eb2) => eb2.fn.min<number | null>('d.day_number').as('m'))
.whereRef('da.place_id', '=', 'p.id')
.whereRef('d.trip_id', '=', 'p.trip_id')
.as('day_number'),
eb
.selectFrom('day_assignments as da2')
.innerJoin('days as d2', 'd2.id', 'da2.day_id')
.select('d2.date')
.whereRef('da2.place_id', '=', 'p.id')
.whereRef('d2.trip_id', '=', 'p.trip_id')
.orderBy('d2.day_number', 'asc')
.limit(1)
.as('date'),
])
.where('p.trip_id', '=', tripId)
.orderBy('p.name', (ob) => ob.collate('nocase').asc())
.execute();
}
/**
* CL57 (`setStatusFromTrip`) — `SELECT id, name, lat, lng,
* google_place_id, google_ftid, osm_id FROM places WHERE trip_id=? AND id
* IN (...)`. `$in` for the dynamic list (rule 17b), the same
* `scopedIds`-shaped raw `trip_id` condition above.
*/
async listByTripAndIds(trip_id: number, ids: number[]): Promise<PlaceMatchRow[]> {
if (ids.length === 0) return [];
return await this.qb('p')
.select(['p.id', 'p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id'])
.where('p.trip_id = ?', [trip_id])
.andWhere({ id: { $in: ids } })
.execute<PlaceMatchRow[]>('all', false);
}
/**
* CL63 (`copyToTrip`'s trip dedup set) — `SELECT name, lat, lng,
* google_place_id, google_ftid, osm_id FROM places WHERE trip_id=?`.
*/
async dedupCandidatesForTrip(trip_id: number): Promise<Omit<PlaceMatchRow, 'id'>[]> {
return await this.qb('p')
.select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id'])
.where({ trip: trip_id })
.execute<Omit<PlaceMatchRow, 'id'>[]>('all', false);
}
/**
* CL64 (`copyToTrip`, PREPARED/looped, TX) — `INSERT INTO places (trip_id,
* name, description, lat, lng, address, category_id, price, currency,
* notes, image_url, google_place_id, google_ftid, website, phone,
* osm_id) VALUES (...)` — a genuinely NARROWER 16-column insert than
* {@link insertPlace}'s full 25-column shape (no `place_time`/`end_time`/
* `duration_minutes`/`transport_mode`/`route_geometry`/`route_color`/
* `stop_type`/`fill_percent`/`amap_poi_id`): the legacy statement leaves
* those to the table's own `DEFAULT` clauses
* (`Migration20200101000000_baseline_schema.ts`: `duration_minutes
* INTEGER DEFAULT 60`, `transport_mode TEXT DEFAULT 'walking'`,
* `reservation_status TEXT DEFAULT 'none'`), unlike `insertPlace`'s own
* caller (`PlacesService.create`), which always resolves and writes every
* one of those columns itself. Kysely (a narrower insert-only interface,
* `CollectionsRepository`'s own `CollectionPlacesWriteKyselyDB`
* precedent for the identical "narrower than the full entity" shape) so
* this genuinely different column set never widens `insertPlace`'s own
* required-field list. Returns the generated id.
*/
async insertFromCollectionPlace(row: {
trip_id: number;
name: string;
description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
currency: string | null;
notes: string | null;
image_url: string | null;
google_place_id: string | null;
google_ftid: string | null;
website: string | null;
phone: string | null;
osm_id: string | null;
}): Promise<number> {
const result = await this.kysely<PlacesNarrowInsertKyselyDB>().insertInto('places').values(row).executeTakeFirstOrThrow();
return Number(result.insertId);
}
}
/** {@link PlacesRepository.listImportable}'s row shape (CL45). */
export interface ImportablePlaceRow {
place_id: number;
name: string;
address: string | null;
lat: number | null;
lng: number | null;
category_id: number | null;
image_url: string | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
day_number: number | null;
date: string | null;
}
/** {@link PlacesRepository.listByTripAndIds}/{@link PlacesRepository.dedupCandidatesForTrip}'s shared narrow projection (CL57/CL63). */
export interface PlaceMatchRow {
id: number;
name: string;
lat: number | null;
lng: number | null;
google_place_id: string | null;
google_ftid: string | null;
osm_id: string | null;
}
/** {@link PlacesRepository.listImportable}'s narrow `places`/`day_assignments`/`days` shape (CL45). */
interface ImportablePlacesKyselyDB {
places: { id: number; trip_id: number; name: string; address: string | null; lat: number | null; lng: number | null; category_id: number | null; image_url: string | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null };
day_assignments: { place_id: number; day_id: number };
days: { id: number; trip_id: number; day_number: number; date: string };
}
/** {@link PlacesRepository.insertFromCollectionPlace}'s narrow insert-only shape (CL64). */
interface PlacesNarrowInsertKyselyDB {
places: {
trip_id: number;
name: string;
description: string | null;
lat: number | null;
lng: number | null;
address: string | null;
category_id: number | null;
price: number | null;
currency: string | null;
notes: string | null;
image_url: string | null;
google_place_id: string | null;
google_ftid: string | null;
website: string | null;
phone: string | null;
osm_id: string | null;
};
}
/** {@link PlacesRepository.listAddressesForUser}'s narrow `places`/`trips`/`trip_members`/`place_regions` shape. */
+42 -1
View File
@@ -1,5 +1,5 @@
import type { Trips } from '../entities/Trips.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { coalesceParam, currentTimestamp, nowDateOffset } from '../dialect/sql-functions';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -844,6 +844,47 @@ export class TripsRepository extends TrekRepository<Trips> {
.executeTakeFirst();
return { trips: Number(row?.trips ?? 0), days: Number(row?.days ?? 0) };
}
// ---------------------------------------------------------------------------
// Plan 3h Task 3 (`DawarichSyncService`/`DawarichTracksService`) — additive.
// ---------------------------------------------------------------------------
/**
* DSY2 (`dawarich-sync.service.ts::listTripsToSync`) — `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`.
* `accessibleTripsQuery` for the LEFT JOIN/OR half (the same join builder
* `findAccessible`/`listReachableActiveTrips` share); `coalesceParam` for
* the `COALESCE(is_archived, 0) = 0` guard (mirrors a nullable column the
* same way the legacy statement does, even though `is_archived` is
* effectively never NULL in practice); `nowDateOffset` (Task 0) for both
* `date('now', …)` literal-offset forms — expressible entirely through
* the QueryBuilder, so no `nowDateOffsetKysely` twin was needed for this
* site (Task 0's own CONTROLLER NOTE flagged this as a possibility, not a
* certainty).
*/
async listTripsToSync(user_id: number): Promise<{ id: number; start_date: string | null; end_date: string | null }[]> {
const platform = this.getEntityManager().getPlatform();
return await this.accessibleTripsQuery(user_id)
.select(['t.id', 't.start_date', 't.end_date'], true)
.andWhere({ [coalesceParam(platform, 'is_archived', 0)]: 0 })
.andWhere({ start_date: { $ne: null } })
.andWhere({ $or: [{ end_date: null }, { end_date: { $gte: nowDateOffset(platform, -400) } }] })
.andWhere({ start_date: { $lte: nowDateOffset(platform, 1) } })
.orderBy({ 't.start_date': 'desc' })
.execute<{ id: number; start_date: string | null; end_date: string | null }[]>('all', false);
}
/** DTR1 (`dawarich-tracks.service.ts::forTrip`) — `SELECT start_date, end_date FROM trips WHERE id = ?`. */
async findDatesById(id: number): Promise<{ start_date: string | null; end_date: string | null } | undefined> {
return await this.qb('t')
.select(['t.start_date', 't.end_date'])
.where({ id })
.execute<{ start_date: string | null; end_date: string | null } | undefined>('get', false);
}
}
/** {@link TripsRepository.lastStartedTrip}'s narrow `trips`/`trip_members` shape. */
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import {
DAWARICH_BUCKET_MATCH_MIN_MINUTES,
DAWARICH_BUCKET_MATCH_RADIUS_M,
@@ -12,7 +13,6 @@ import {
type DawarichSuggestion,
type DawarichSuggestionList,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AtlasService } from '../atlas/atlas.service';
import { PlacesService } from '../places/places.service';
@@ -20,6 +20,16 @@ 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 { DawarichVisitSuggestions } from '../../db/entities/DawarichVisitSuggestions.entity';
import { DawarichVisitSuggestionsRepository, type SuggestionJoinRow, type SuggestionRow } from '../../db/repositories/DawarichVisitSuggestions.repository';
import { Trips } from '../../db/entities/Trips.entity';
import { TripsRepository } from '../../db/repositories/Trips.repository';
import { Places } from '../../db/entities/Places.entity';
import { PlacesRepository } from '../../db/repositories/Places.repository';
import { BucketList } from '../../db/entities/BucketList.entity';
import { BucketListRepository } from '../../db/repositories/BucketList.repository';
import { Users } from '../../db/entities/Users.entity';
import { UsersRepository } from '../../db/repositories/Users.repository';
import { DawarichClient, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import { minutesBetween, toNumber } from './dawarich.helpers';
@@ -46,7 +56,7 @@ import { minutesBetween, toNumber } from './dawarich.helpers';
@Injectable()
export class DawarichSuggestionsService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(DawarichVisitSuggestions) private readonly suggestions: DawarichVisitSuggestionsRepository,
private readonly dawarich: DawarichService,
private readonly client: DawarichClient,
private readonly atlas: AtlasService,
@@ -54,6 +64,10 @@ export class DawarichSuggestionsService {
private readonly assignments: AssignmentsService,
private readonly permissions: PermissionsService,
private readonly journey: JourneyDomainService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
@InjectRepository(BucketList) private readonly bucketList: BucketListRepository,
@InjectRepository(Users) private readonly users: UsersRepository,
private readonly uow: UnitOfWork,
) {}
@@ -67,26 +81,7 @@ export class DawarichSuggestionsService {
async list(userId: number, filter: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> {
await 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 rows = await this.suggestions.list(userId, filter);
const connection = await this.dawarich.getConnection(userId);
return {
@@ -116,36 +111,11 @@ export class DawarichSuggestionsService {
* still refused the row as already accepted.
*/
private async reopenOrphaned(userId: number): Promise<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,
);
await this.suggestions.reopenOrphaned(userId);
}
async getOne(userId: number, id: number): Promise<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,
);
const row = await this.suggestions.getOne(userId, id);
return row ? toWire(row) : null;
}
@@ -158,15 +128,11 @@ export class DawarichSuggestionsService {
* "undo" would have to guess whether to delete it.
*/
async setState(userId: number, id: number, state: 'new' | 'dismissed'): Promise<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,
);
const row = await this.suggestions.findStateForUser(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);
await this.suggestions.setState(id, state);
return this.getOne(userId, id);
}
@@ -178,11 +144,7 @@ export class DawarichSuggestionsService {
* atlas domain's `BucketItemExistsError` is the precedent.
*/
async accept(userId: number, id: number, body: DawarichAccept, sid?: string): Promise<DawarichAcceptResult> {
const row = this.db.get<SuggestionRow>(
'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
id,
userId,
);
const row = await this.suggestions.findForUser(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);
@@ -212,7 +174,7 @@ export class DawarichSuggestionsService {
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 = await this.db.canAccessTrip(tripId, userId);
const access = await this.trips.findAccessible(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
@@ -253,14 +215,14 @@ export class DawarichSuggestionsService {
// 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);
await this.placesRepo.setSource(id, 'dawarich');
const day = body.dayId !== undefined ? await this.assignments.createAssignment(body.dayId, id, null) : undefined;
await 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: (await this.db.getPlaceWithTags(id)) ?? place, placeId: id, assignment: day };
return { created: (await this.placesRepo.findWithTagsAndRatings(id)) ?? place, placeId: id, assignment: day };
});
// Outside the transaction, because both reach past the database: a
@@ -348,20 +310,11 @@ export class DawarichSuggestionsService {
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,
);
const item = await this.bucketList.findForUser(itemId, userId);
if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404);
await this.uow.transactional(async () => {
this.db.run(
"UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?",
row.started_at,
itemId,
userId,
);
await this.bucketList.markVisited(itemId, userId, row.started_at);
await this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId });
});
@@ -382,10 +335,10 @@ export class DawarichSuggestionsService {
* somebody came through would not be a permission.
*/
private async requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): Promise<void> {
const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
const role = await this.users.getRole(userId);
const allowed = await this.permissions.checkPermission(
action,
actor?.role ?? 'user',
role ?? 'user',
tripOwnerId,
userId,
tripOwnerId !== userId,
@@ -405,18 +358,12 @@ export class DawarichSuggestionsService {
target: 'place' | 'journal' | 'bucket_list',
ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number },
): Promise<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 = ?`,
await this.suggestions.markAccepted(id, {
target,
ids.placeId ?? null,
ids.journalEntryId ?? null,
ids.bucketItemId ?? null,
id,
);
placeId: ids.placeId ?? null,
journalEntryId: ids.journalEntryId ?? null,
bucketItemId: ids.bucketItemId ?? null,
});
}
// ── Bucket-list scan ───────────────────────────────────────────────────────
@@ -438,12 +385,7 @@ export class DawarichSuggestionsService {
const creds = await 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 items: BucketRow[] = await this.bucketList.listForScan(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);
@@ -526,13 +468,7 @@ export class DawarichSuggestionsService {
// knows better: the caller's own value first, then the stay this wish
// was matched to, then the clock.
const stamp = visitedAt ?? (await 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;
updated += await this.bucketList.markVisitedIfUnset(itemId, userId, stamp);
}
});
return updated;
@@ -540,24 +476,12 @@ export class DawarichSuggestionsService {
/** When the stay that claimed this wish began, if one did. */
private async matchedStayStart(userId: number, itemId: number): Promise<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;
return this.suggestions.matchedStayStart(userId, itemId);
}
/** Undo a tick. Clears the source with it, so the entry reads as untouched again. */
async clearBucketVisit(userId: number, itemId: number): Promise<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;
return this.bucketList.clearVisited(itemId, userId);
}
// ── Atlas ──────────────────────────────────────────────────────────────────
@@ -699,40 +623,6 @@ function isoFromUnix(seconds: number | null | undefined): string | 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;
@@ -1,6 +1,8 @@
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { logInfo, logError } from '../audit/audit-log.logger';
import { DatabaseService } from '../database/database.service';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { DawarichSyncService } from './dawarich-sync.service';
@@ -20,25 +22,23 @@ import { DawarichSyncService } from './dawarich-sync.service';
@Injectable()
export class DawarichSyncJob implements OnApplicationBootstrap {
constructor(
private readonly db: DatabaseService,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
private readonly sync: DawarichSyncService,
private readonly registrar: CronRegistrarService,
) {}
async onApplicationBootstrap(): Promise<void> {
if (!this.registrar.isEnabled()) return;
// Through runOnBoot (task-6-review-parity.md C1: raw SQL today, but the
// wrap belongs at the entrypoint so it stays safe if this dependency
// graph goes repository-backed later). minutes defaults to the same
// fallback the interval read itself falls back to, so an absent MikroORM
// (logged distinctly by runOnBoot, never silently) still registers the
// job at its default cadence rather than not registering at all.
// Through runOnBoot (task-6-review-parity.md C1 — the wrap belongs at the
// entrypoint regardless of whether the read underneath is raw SQL or
// repository-backed, DSJ1 now converted onto `AppSettingsRepository`).
// minutes defaults to the same fallback the interval read itself falls
// back to, so an absent MikroORM (logged distinctly by runOnBoot, never
// silently) still registers the job at its default cadence rather than
// not registering at all.
let minutes = 15;
await this.registrar.runOnBoot('dawarich-sync-boot', async () => {
const value = this.db.get<{ value: string }>(
'SELECT value FROM app_settings WHERE key = ?',
'dawarich_poll_interval_minutes',
)?.value;
const value = await this.appSettings.getValue('dawarich_poll_interval_minutes');
const raw = Number.parseInt(value || '15', 10);
minutes = Number.isFinite(raw) && raw >= 5 && raw <= 59 ? raw : 15;
logInfo(`Dawarich sync: scheduled every ${minutes}m`);
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import {
DAWARICH_SYNC_LOOKAHEAD_DAYS,
DAWARICH_SYNC_LOOKBACK_DAYS,
@@ -7,11 +8,18 @@ import {
type DawarichSyncState,
} from '@trek/shared';
import { ADDON_IDS } from '../../addons';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AddonsService } from '../addons/addons.service';
import { logError, logInfo } from '../audit/audit-log.logger';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { DawarichVisitSuggestions } from '../../db/entities/DawarichVisitSuggestions.entity';
import { DawarichVisitSuggestionsRepository } from '../../db/repositories/DawarichVisitSuggestions.repository';
import { Trips } from '../../db/entities/Trips.entity';
import { TripsRepository } from '../../db/repositories/Trips.repository';
import { BucketList } from '../../db/entities/BucketList.entity';
import { BucketListRepository } from '../../db/repositories/BucketList.repository';
import { DawarichConnections } from '../../db/entities/DawarichConnections.entity';
import { DawarichConnectionsRepository } from '../../db/repositories/DawarichConnections.repository';
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import { distanceMeters, localDateOf, normalizeVisit, syncWindow, visitHash } from './dawarich.helpers';
@@ -67,10 +75,13 @@ export class DawarichSyncService {
private readonly inFlight = new Set<number>();
constructor(
private readonly db: DatabaseService,
@InjectRepository(DawarichVisitSuggestions) private readonly suggestions: DawarichVisitSuggestionsRepository,
private readonly addons: AddonsService,
private readonly client: DawarichClient,
private readonly dawarich: DawarichService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(BucketList) private readonly bucketList: BucketListRepository,
@InjectRepository(DawarichConnections) private readonly connections: DawarichConnectionsRepository,
private readonly uow: UnitOfWork,
) {}
@@ -112,12 +123,9 @@ export class DawarichSyncService {
// 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,
);
const currentState = await this.connections.getLastSyncState(userId);
return {
state: (current?.last_sync_state as DawarichSyncState) ?? 'never',
state: (currentState as DawarichSyncState) ?? 'never',
created: 0,
updated: 0,
missing: 0,
@@ -212,19 +220,7 @@ export class DawarichSyncService {
* first time wants their last holiday filled in, not just today's.
*/
private async listTripsToSync(userId: number): Promise<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,
);
return this.trips.listTripsToSync(userId);
}
/**
@@ -261,11 +257,7 @@ export class DawarichSyncService {
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,
);
const existing = await this.suggestions.findByUserAndVisit(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
@@ -278,30 +270,25 @@ export class DawarichSyncService {
const ownerTripId = tripForVisit(visit.localDate, tripId, existing?.trip_id ?? null, trips);
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,
ownerTripId,
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(),
);
await this.suggestions.insertVisit({
user_id: userId,
source_visit_id: visit.sourceVisitId,
trip_id: ownerTripId,
name: visit.name,
lat: visit.lat,
lng: visit.lng,
started_at: visit.startedAt,
ended_at: visit.endedAt,
duration_minutes: visit.durationMinutes,
local_date: visit.localDate,
source_status: visit.status,
confidence: visit.confidence,
confidence_band: visit.confidenceBand,
country_code: countryCode,
source_hash: hash,
first_seen_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
});
created++;
await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
continue;
@@ -314,29 +301,22 @@ export class DawarichSyncService {
// simply the better suggestion. Overwriting here loses nothing, and
// that includes the trip: a stay parked on a neighbour by an earlier
// run moves to the trip whose dates hold it.
this.db.run(
`UPDATE dawarich_visit_suggestions
SET name = ?, lat = ?, lng = ?, started_at = ?, ended_at = ?, duration_minutes = ?,
local_date = ?, source_status = ?, confidence = ?, confidence_band = ?,
country_code = ?, source_hash = ?, source_missing_at = NULL,
trip_id = ?, 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,
ownerTripId,
new Date().toISOString(),
existing.id,
);
await this.suggestions.updateNewVisit(existing.id, {
name: visit.name,
lat: visit.lat,
lng: visit.lng,
started_at: visit.startedAt,
ended_at: visit.endedAt,
duration_minutes: visit.durationMinutes,
local_date: visit.localDate,
source_status: visit.status,
confidence: visit.confidence,
confidence_band: visit.confidenceBand,
country_code: countryCode,
source_hash: hash,
trip_id: ownerTripId,
last_seen_at: new Date().toISOString(),
});
if (changed) updated++;
await this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
continue;
@@ -346,14 +326,7 @@ export class DawarichSyncService {
// 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,
);
await this.suggestions.refreshHash(existing.id, hash, new Date().toISOString());
if (changed) updated++;
}
@@ -385,11 +358,7 @@ export class DawarichSyncService {
// 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 <= ?`,
const candidates = await this.suggestions.listCandidatesForWindow(
userId,
tripId,
localDateOf(from.toISOString()),
@@ -403,13 +372,9 @@ export class DawarichSyncService {
await this.uow.transactional(async () => {
for (const row of gone) {
if (row.state === 'new') {
this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id);
await this.suggestions.deleteById(row.id);
} else {
this.db.run(
'UPDATE dawarich_visit_suggestions SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?',
stamp,
row.id,
);
await this.suggestions.stampMissingIfUnset(row.id, stamp);
}
}
});
@@ -439,19 +404,11 @@ export class DawarichSyncService {
// 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,
);
const nearby = await this.bucketList.listInBoundingBox(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) {
if (item.lat === null || item.lng === null) continue;
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 };
@@ -483,20 +440,7 @@ export class DawarichSyncService {
lng: number,
durationMinutes: number,
): Promise<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,
);
const holders = await this.suggestions.listHoldersOfWish(userId, wish.id, sourceVisitId);
for (const holder of holders) {
if (holder.lat === null || holder.lng === null) continue;
@@ -508,17 +452,8 @@ export class DawarichSyncService {
}
await this.uow.transactional(async () => {
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,
);
await this.suggestions.clearWishHolders(userId, wish.id);
await this.suggestions.assignWishHolder(userId, sourceVisitId, wish.id);
});
}
}
@@ -529,13 +464,6 @@ interface TripRow {
end_date: string | null;
}
interface SuggestionRow {
id: number;
trip_id: number | null;
state: string;
source_hash: string;
}
/**
* Does the trip's own span, not its widened window, hold this local date?
*
@@ -1,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { Trips } from '../../db/entities/Trips.entity';
import { TripsRepository } from '../../db/repositories/Trips.repository';
import { DawarichClient, type DawarichCreds } from './dawarich.client';
import { DawarichService } from './dawarich.service';
import {
@@ -41,7 +43,7 @@ export class DawarichTracksService {
private static readonly MAX_ENTRIES = 64;
constructor(
private readonly db: DatabaseService,
@InjectRepository(Trips) private readonly trips: TripsRepository,
private readonly dawarich: DawarichService,
private readonly client: DawarichClient,
) {}
@@ -61,12 +63,9 @@ export class DawarichTracksService {
to?: string,
offsetMinutes = 0,
): Promise<DawarichTrack | null> {
if (!(await this.db.canAccessTrip(tripId, userId))) return null;
if (!(await this.trips.findAccessible(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,
);
const trip = await this.trips.findDatesById(tripId);
if (!trip) return null;
const start = from ?? trip.start_date;
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AddonsModule } from '../addons/addons.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
@@ -9,6 +10,13 @@ import { AssignmentsDomainModule } from '../assignments/assignments-domain.modul
import { JourneyDomainModule } from '../journey/journey-domain.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { DawarichConnections } from '../../db/entities/DawarichConnections.entity';
import { DawarichVisitSuggestions } from '../../db/entities/DawarichVisitSuggestions.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { Places } from '../../db/entities/Places.entity';
import { BucketList } from '../../db/entities/BucketList.entity';
import { Users } from '../../db/entities/Users.entity';
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { DawarichClient } from './dawarich.client';
import { DawarichController } from './dawarich.controller';
import { DawarichMcp } from './dawarich.mcp';
@@ -33,9 +41,22 @@ import { DawarichTracksService } from './dawarich-tracks.service';
* than `AssignmentsModule`, and `JourneyDomainModule` rather than
* `JourneyModule`, so accepting a suggestion does not drag two controller
* stacks and both photo providers into this graph.
*
* `MikroOrmModule.forFeature` registers every entity this module's own
* services' `@InjectRepository` constructors need (Plan 3h Task 3):
* `DawarichConnections`/`DawarichVisitSuggestions` (this domain's own two
* tables) plus the cross-domain repositories `DawarichSuggestionsService`/
* `DawarichSyncService`/`DawarichTracksService`/`DawarichSyncJob` reach —
* `Trips` (3c, access + sync candidates), `Places` (3c, the `source` stamp),
* `BucketList` (3f, ticks/scan/bounding-box), `Users` (3b, the role lookup
* `requirePermission` needs) and `AppSettings` (3a, the poll-interval
* setting) — every module that CONSTRUCTS these services needs its own
* registration of the entity, not only the entity's own home module (the
* program-wide BOOT GATE rule).
*/
@Module({
imports: [
MikroOrmModule.forFeature([DawarichConnections, DawarichVisitSuggestions, Trips, Places, BucketList, Users, AppSettings]),
AddonsModule,
AuditModule,
// The MCP tools ask AuthService whether the caller is the demo user, the
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import {
DAWARICH_KEY_MASK,
type DawarichCapabilities,
@@ -6,7 +7,8 @@ import {
type DawarichStatus,
type DawarichSyncState,
} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { DawarichConnections } from '../../db/entities/DawarichConnections.entity';
import { DawarichConnectionsRepository, type DawarichConnectionRow } from '../../db/repositories/DawarichConnections.repository';
import { AuditService } from '../audit/audit.service';
import { UnitOfWork } from '../database/unit-of-work';
import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
@@ -31,19 +33,14 @@ import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.cl
@Injectable()
export class DawarichService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(DawarichConnections) private readonly connections: DawarichConnectionsRepository,
private readonly audit: AuditService,
private readonly client: DawarichClient,
private readonly uow: UnitOfWork,
) {}
private async readRow(userId: number): Promise<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,
);
private async readRow(userId: number): Promise<DawarichConnectionRow | null> {
return this.connections.findRow(userId);
}
/** Decrypted credentials for an outbound call, or null when nothing is connected. */
@@ -63,11 +60,7 @@ export class DawarichService {
/** Every user whose connection is usable and whose background sync is on. */
async listSyncableUserIds(): Promise<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);
return this.connections.listSyncableUserIds();
}
/** The connection as the settings card shows it. The key is a mask, always. */
@@ -136,10 +129,7 @@ export class DawarichService {
}
}
const previousUrl = this.db.get<{ url: string | null }>(
'SELECT url FROM dawarich_connections WHERE user_id = ?',
userId,
)?.url ?? null;
const previousUrl = (await this.connections.getUrl(userId)) ?? null;
const provided = (apiKey || '').trim();
const newKey = provided && provided !== DAWARICH_KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
@@ -148,22 +138,14 @@ export class DawarichService {
// key would point a credential minted for one instance at another one, and
// the next sync would send it there.
await this.uow.transactional(async () => {
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,
);
await this.connections.upsertConnection(userId, {
url: trimmedUrl || null,
allowInsecureTls,
syncEnabled,
});
if (newKey !== undefined) {
this.db.run('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?', newKey, userId);
await this.connections.setApiKey(userId, newKey);
}
// A key belongs to the instance it was minted on. Pointing the connection
@@ -177,15 +159,9 @@ export class DawarichService {
// this row no longer points at, and a fresh key does not make them true
// of the new one.
if (movedHost(previousUrl, trimmedUrl)) {
this.db.run(
`UPDATE dawarich_connections
SET capabilities = NULL, last_sync_state = 'never',
last_sync_error = NULL, last_sync_at = NULL
WHERE user_id = ?`,
userId,
);
await this.connections.resetSyncState(userId);
if (newKey === undefined) {
this.db.run('UPDATE dawarich_connections SET api_key = NULL WHERE user_id = ?', userId);
await this.connections.setApiKey(userId, null);
}
}
@@ -193,13 +169,7 @@ export class DawarichService {
// 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,
);
await this.connections.clearForRemovedUrl(userId);
}
});
@@ -215,7 +185,7 @@ export class DawarichService {
* destroying user content on its way out. What goes is the credential.
*/
async disconnect(userId: number, clientIp: string | null): Promise<void> {
this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId);
await this.connections.disconnect(userId);
await this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
}
@@ -338,38 +308,14 @@ export class DawarichService {
}
async storeCapabilities(userId: number, capabilities: DawarichCapabilities): Promise<void> {
this.db.run(
'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?',
JSON.stringify(capabilities),
userId,
);
await this.connections.storeCapabilities(userId, JSON.stringify(capabilities));
}
async recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): Promise<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,
);
await this.connections.recordSyncResult(userId, { lastSyncAt: new Date().toISOString(), state, error });
}
}
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
+40
View File
@@ -0,0 +1,40 @@
import type Database from 'better-sqlite3';
import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity';
import type { DawarichConnectionsRepository } from '../../src/db/repositories/DawarichConnections.repository';
import { DawarichVisitSuggestions } from '../../src/db/entities/DawarichVisitSuggestions.entity';
import type { DawarichVisitSuggestionsRepository } from '../../src/db/repositories/DawarichVisitSuggestions.repository';
import { sharedTestOrm } from './test-uow';
/**
* Plan 3h Task 3 (dawarich connection/credentials, sync/reconcile, review
* flow) — the two repositories this domain's own tables need, for a
* hand-constructed `DawarichService`/`DawarichSuggestionsService`/
* `DawarichSyncService`. Same memoisation-per-handle pattern every helper in
* `test-uow.ts` uses: one ORM per test file's better-sqlite3 handle, shared
* with whatever `UnitOfWork`/other repository that same file also builds —
* `sharedTestOrm` (not a second `createTestOrm` call) is what keeps a
* repository built here and a `UnitOfWork` built via `createTestUnitOfWork`
* resolving the SAME context-forking `EntityManager`, so a write inside
* `uow.transactional(...)` through either one is actually inside the open
* transaction.
*/
const connectionsPerHandle = new WeakMap<Database.Database, Promise<DawarichConnectionsRepository>>();
const suggestionsPerHandle = new WeakMap<Database.Database, Promise<DawarichVisitSuggestionsRepository>>();
/** The `DawarichConnectionsRepository` a hand-constructed `DawarichService`/`DawarichSyncService` needs. */
export function createTestDawarichConnectionsRepo(db: Database.Database): Promise<DawarichConnectionsRepository> {
const existing = connectionsPerHandle.get(db);
if (existing !== undefined) return existing;
const pending = sharedTestOrm(db).then((t) => t.repo(DawarichConnections));
connectionsPerHandle.set(db, pending);
return pending;
}
/** The `DawarichVisitSuggestionsRepository` a hand-constructed `DawarichSuggestionsService`/`DawarichSyncService` needs. */
export function createTestDawarichVisitSuggestionsRepo(db: Database.Database): Promise<DawarichVisitSuggestionsRepository> {
const existing = suggestionsPerHandle.get(db);
if (existing !== undefined) return existing;
const pending = sharedTestOrm(db).then((t) => t.repo(DawarichVisitSuggestions));
suggestionsPerHandle.set(db, pending);
return pending;
}
@@ -0,0 +1,250 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { DawarichConnections } from '../../../../src/db/entities/DawarichConnections.entity';
import type { DawarichConnectionsRepository } from '../../../../src/db/repositories/DawarichConnections.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let connections: DawarichConnectionsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
connections = t.repo(DawarichConnections);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
testDb.exec('DELETE FROM dawarich_connections');
});
afterAll(async () => {
await t.close();
testDb.close();
});
function seed(userId: number, over: Partial<{
url: string | null;
api_key: string | null;
allow_insecure_tls: number;
sync_enabled: number;
last_sync_at: string | null;
last_sync_state: string;
last_sync_error: string | null;
capabilities: string | null;
}> = {}): void {
testDb
.prepare(
`INSERT OR REPLACE INTO dawarich_connections
(user_id, url, api_key, allow_insecure_tls, sync_enabled, last_sync_at, last_sync_state, last_sync_error, capabilities)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
over.url === undefined ? 'https://dawarich.example' : over.url,
over.api_key === undefined ? 'enc:v1:cipher' : over.api_key,
over.allow_insecure_tls ?? 0,
over.sync_enabled ?? 1,
over.last_sync_at ?? null,
over.last_sync_state ?? 'never',
over.last_sync_error ?? null,
over.capabilities ?? null,
);
}
function raw(userId: number): Record<string, unknown> | undefined {
return testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(userId) as Record<string, unknown> | undefined;
}
describe('DawarichConnectionsRepository', () => {
describe('findRow (DWC1)', () => {
it('CONNREPO-001: null when no connection exists', async () => {
const { user } = createUser(testDb);
expect(await connections.findRow(user.id)).toBeNull();
});
it('CONNREPO-002: a fully-seeded row comes back byte-identical to a legacy SELECT of the same 9 columns', async () => {
const { user } = createUser(testDb);
seed(user.id, {
url: 'https://dawarich.example/api',
api_key: 'enc:v1:abcdef',
allow_insecure_tls: 1,
sync_enabled: 0,
last_sync_at: '2026-09-12T04:15:00.000Z',
last_sync_state: 'partial',
last_sync_error: 'rate_limited',
capabilities: '{"visits":true}',
});
const row = await connections.findRow(user.id);
const legacy = raw(user.id)!;
expect(row).toEqual({
user_id: legacy.user_id,
url: legacy.url,
api_key: legacy.api_key,
allow_insecure_tls: legacy.allow_insecure_tls,
sync_enabled: legacy.sync_enabled,
last_sync_at: legacy.last_sync_at,
last_sync_state: legacy.last_sync_state,
last_sync_error: legacy.last_sync_error,
capabilities: legacy.capabilities,
});
});
});
describe('listSyncableUserIds (DWC2)', () => {
it('CONNREPO-010: only rows with sync on, a non-blank url and a key', async () => {
const syncable = createUser(testDb).user.id;
const pollOff = createUser(testDb).user.id;
const noUrl = createUser(testDb).user.id;
const blankUrl = createUser(testDb).user.id;
const noKey = createUser(testDb).user.id;
seed(syncable);
seed(pollOff, { sync_enabled: 0 });
seed(noUrl, { url: null });
seed(blankUrl, { url: '' });
seed(noKey, { api_key: null });
expect(await connections.listSyncableUserIds()).toEqual([syncable]);
});
});
describe('getUrl (DWC3)', () => {
it('CONNREPO-020: the stored url, or null for no row / a null column', async () => {
const { user } = createUser(testDb);
expect(await connections.getUrl(user.id)).toBeNull();
seed(user.id, { url: 'https://d.example' });
expect(await connections.getUrl(user.id)).toBe('https://d.example');
seed(user.id, { url: null });
expect(await connections.getUrl(user.id)).toBeNull();
});
});
describe('upsertConnection (DWC4)', () => {
it('CONNREPO-030: inserts a new row with the other columns at their schema defaults', async () => {
const { user } = createUser(testDb);
await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: true, syncEnabled: false });
const row = raw(user.id)!;
expect(row).toMatchObject({ url: 'https://d.example', allow_insecure_tls: 1, sync_enabled: 0, api_key: null, capabilities: null, last_sync_state: 'never' });
expect(row.updated_at).toBeTruthy();
});
it('CONNREPO-031: on conflict merges ONLY url/allow_insecure_tls/sync_enabled/updated_at — api_key and capabilities survive untouched', async () => {
const { user } = createUser(testDb);
seed(user.id, { url: 'https://old.example', api_key: 'enc:v1:stored', capabilities: '{"visits":true}', last_sync_state: 'ok' });
await connections.upsertConnection(user.id, { url: 'https://new.example', allowInsecureTls: true, syncEnabled: true });
const row = raw(user.id)!;
expect(row.url).toBe('https://new.example');
expect(row.allow_insecure_tls).toBe(1);
expect(row.api_key).toBe('enc:v1:stored');
expect(row.capabilities).toBe('{"visits":true}');
expect(row.last_sync_state).toBe('ok');
});
it('CONNREPO-032: updated_at is bumped unconditionally, insert or merge alike', async () => {
const { user } = createUser(testDb);
await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: false, syncEnabled: true });
testDb.exec(`UPDATE dawarich_connections SET updated_at = '2000-01-01 00:00:00'`);
await connections.upsertConnection(user.id, { url: 'https://d.example', allowInsecureTls: false, syncEnabled: false });
expect(raw(user.id)!.updated_at).not.toBe('2000-01-01 00:00:00');
});
});
describe('setApiKey (DWC5/DWC7)', () => {
it('CONNREPO-040: writes the given ciphertext, and null scrubs it', async () => {
const { user } = createUser(testDb);
seed(user.id, { api_key: 'enc:v1:old' });
await connections.setApiKey(user.id, 'enc:v1:new');
expect(raw(user.id)!.api_key).toBe('enc:v1:new');
await connections.setApiKey(user.id, null);
expect(raw(user.id)!.api_key).toBeNull();
});
});
describe('resetSyncState (DWC6)', () => {
it('CONNREPO-050: clears capabilities/last_sync_* without touching api_key', async () => {
const { user } = createUser(testDb);
seed(user.id, { api_key: 'enc:v1:kept', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
await connections.resetSyncState(user.id);
const row = raw(user.id)!;
expect(row).toMatchObject({ api_key: 'enc:v1:kept', capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
});
});
describe('clearForRemovedUrl (DWC8)', () => {
it('CONNREPO-060: clears api_key together with capabilities/last_sync_* in one statement', async () => {
const { user } = createUser(testDb);
seed(user.id, { api_key: 'enc:v1:gone', capabilities: '{"a":1}', last_sync_state: 'ok', last_sync_error: 'x', last_sync_at: '2026-01-01T00:00:00Z' });
await connections.clearForRemovedUrl(user.id);
expect(raw(user.id)!).toMatchObject({ api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null });
});
});
describe('disconnect (DWC9)', () => {
it('CONNREPO-070: removes only the named user\'s row, and does not cascade to suggestions (no FK relation exists for it to cascade through)', async () => {
const { user } = createUser(testDb);
const other = createUser(testDb).user.id;
seed(user.id);
seed(other);
testDb
.prepare(
`INSERT INTO dawarich_visit_suggestions
(user_id, source_visit_id, name, lat, lng, started_at, ended_at, local_date, source_hash)
VALUES (?, 'v1', 'Cafe', 1, 1, '2026-01-01T00:00:00Z', '2026-01-01T01:00:00Z', '2026-01-01', 'hash')`,
)
.run(user.id);
await connections.disconnect(user.id);
expect(raw(user.id)).toBeUndefined();
expect(raw(other)).toBeDefined();
expect(testDb.prepare('SELECT COUNT(*) AS n FROM dawarich_visit_suggestions WHERE user_id = ?').get(user.id)).toEqual({ n: 1 });
});
});
describe('storeCapabilities (DWC10)', () => {
it('CONNREPO-080: writes the JSON blob and stamps updated_at', async () => {
const { user } = createUser(testDb);
seed(user.id);
await connections.storeCapabilities(user.id, '{"visits":true,"tracks":false}');
expect(raw(user.id)!.capabilities).toBe('{"visits":true,"tracks":false}');
});
});
describe('recordSyncResult (DWC11)', () => {
it('CONNREPO-090: writes all four columns for the named user only', async () => {
const { user } = createUser(testDb);
const other = createUser(testDb).user.id;
seed(user.id);
seed(other, { last_sync_state: 'ok' });
await connections.recordSyncResult(user.id, { lastSyncAt: '2026-09-12T04:15:00.000Z', state: 'failed', error: 'unreachable' });
expect(raw(user.id)!).toMatchObject({ last_sync_at: '2026-09-12T04:15:00.000Z', last_sync_state: 'failed', last_sync_error: 'unreachable' });
expect(raw(other)!.last_sync_state).toBe('ok');
});
});
describe('getLastSyncState (DSY1)', () => {
it('CONNREPO-100: the stored state, or null with no row at all', async () => {
const { user } = createUser(testDb);
expect(await connections.getLastSyncState(user.id)).toBeNull();
seed(user.id, { last_sync_state: 'partial' });
expect(await connections.getLastSyncState(user.id)).toBe('partial');
});
});
});
@@ -0,0 +1,421 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser, createTrip } from '../../../helpers/factories';
import { DawarichVisitSuggestions } from '../../../../src/db/entities/DawarichVisitSuggestions.entity';
import type { DawarichVisitSuggestionsRepository } from '../../../../src/db/repositories/DawarichVisitSuggestions.repository';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let suggestions: DawarichVisitSuggestionsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
suggestions = t.repo(DawarichVisitSuggestions);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
testDb.exec('DELETE FROM dawarich_visit_suggestions');
});
afterAll(async () => {
await t.close();
testDb.close();
});
let _seq = 0;
function seed(
userId: number,
over: Partial<{
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;
}> = {},
): number {
_seq++;
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, source_status, confidence, confidence_band, country_code, state, target,
accepted_place_id, accepted_journal_entry_id, accepted_bucket_list_item_id,
matched_bucket_list_item_id, source_hash, accepted_hash, source_missing_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
userId,
over.source_visit_id ?? `visit-${_seq}`,
over.trip_id ?? null,
over.name ?? 'Cafe Central',
over.lat === undefined ? 48.2092 : over.lat,
over.lng === undefined ? 16.3658 : over.lng,
over.started_at ?? '2026-09-01T09:30:00Z',
over.ended_at ?? '2026-09-01T11:45:00Z',
over.duration_minutes ?? 135,
over.local_date ?? '2026-09-01',
over.source_status ?? 'suggested',
over.confidence ?? 0.7,
over.confidence_band ?? 'high',
over.country_code ?? 'AT',
over.state ?? 'new',
over.target ?? null,
over.accepted_place_id ?? null,
over.accepted_journal_entry_id ?? null,
over.accepted_bucket_list_item_id ?? null,
over.matched_bucket_list_item_id ?? null,
over.source_hash ?? `hash-${_seq}`,
over.accepted_hash ?? null,
over.source_missing_at ?? null,
);
return Number(result.lastInsertRowid);
}
function rawRow(id: number): Record<string, unknown> {
return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as Record<string, unknown>;
}
function seedPlace(tripId: number): 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, category_id) VALUES (?, ?, ?)').run(tripId, 'A place', category?.id ?? null);
return Number(result.lastInsertRowid);
}
function seedJourneyEntry(userId: number): number {
const journeyId = Number(
testDb.prepare("INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)").run(userId).lastInsertRowid,
);
return 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, userId).lastInsertRowid,
);
}
function seedBucketItem(userId: number): number {
return Number(testDb.prepare('INSERT INTO bucket_list (user_id, name) VALUES (?, ?)').run(userId, 'Wish').lastInsertRowid);
}
describe('DawarichVisitSuggestionsRepository', () => {
describe('list/getOne (DWS1/DWS3)', () => {
it('DVSREPO-001: a fully-seeded row (with a trip and a matched wish) joins trip_title/matched_bucket_name and every scalar column, matching a legacy raw join', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Vienna' });
const wish = seedBucketItem(user.id);
const id = seed(user.id, { trip_id: trip.id, matched_bucket_list_item_id: wish, name: 'Cafe Central' });
const rows = await suggestions.list(user.id, {});
const legacy = testDb
.prepare(
`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.user_id = ?`,
)
.get(user.id);
expect(rows).toHaveLength(1);
expect(rows[0]).toEqual(legacy);
expect(rows[0].id).toBe(id);
expect(rows[0].trip_title).toBe('Vienna');
expect(rows[0].matched_bucket_name).toBe('Wish');
});
it('DVSREPO-002: scoped by user_id — a stranger\'s row never appears', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
seed(stranger.id);
expect(await suggestions.list(user.id, {})).toEqual([]);
});
it('DVSREPO-003: filters by tripId and state, ordered started_at DESC, id DESC', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const older = seed(user.id, { trip_id: trip.id, started_at: '2026-09-01T00:00:00Z' });
const newer = seed(user.id, { trip_id: trip.id, started_at: '2026-09-02T00:00:00Z' });
seed(user.id, { started_at: '2026-09-03T00:00:00Z' }); // no trip
const byTrip = await suggestions.list(user.id, { tripId: trip.id });
expect(byTrip.map((r) => r.id)).toEqual([newer, older]);
seed(user.id, { trip_id: trip.id, state: 'dismissed' });
const byState = await suggestions.list(user.id, { state: 'dismissed' });
expect(byState).toHaveLength(1);
expect(byState[0].state).toBe('dismissed');
});
it('DVSREPO-004: getOne is the single-row variant, still user-scoped', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const id = seed(user.id);
expect(await suggestions.getOne(stranger.id, id)).toBeNull();
expect((await suggestions.getOne(user.id, id))?.id).toBe(id);
});
});
describe('reopenOrphaned (DWS2)', () => {
it('DVSREPO-010: place target with a NULL accepted_place_id is reopened', async () => {
const { user } = createUser(testDb);
const id = seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: null });
await suggestions.reopenOrphaned(user.id);
const row = rawRow(id);
expect(row.state).toBe('new');
expect(row.target).toBeNull();
});
it('DVSREPO-011: bucket_list target with a NULL accepted_bucket_list_item_id is reopened', async () => {
const { user } = createUser(testDb);
const id = seed(user.id, { state: 'accepted', target: 'bucket_list', accepted_bucket_list_item_id: null });
await suggestions.reopenOrphaned(user.id);
expect(rawRow(id).state).toBe('new');
});
it('DVSREPO-012: journal target whose entry no longer exists (cross-domain NOT EXISTS against journey_entries) is reopened', async () => {
const { user } = createUser(testDb);
const entryId = seedJourneyEntry(user.id);
const id = seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: entryId });
// Still exists: not reopened.
await suggestions.reopenOrphaned(user.id);
expect(rawRow(id).state).toBe('accepted');
testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
await suggestions.reopenOrphaned(user.id);
expect(rawRow(id).state).toBe('new');
expect(rawRow(id).accepted_journal_entry_id).toBeNull();
});
it('DVSREPO-013: journal target with a NULL accepted_journal_entry_id is reopened without needing a journey_entries lookup at all', async () => {
const { user } = createUser(testDb);
const id = seed(user.id, { state: 'accepted', target: 'journal', accepted_journal_entry_id: null });
await suggestions.reopenOrphaned(user.id);
expect(rawRow(id).state).toBe('new');
});
it('DVSREPO-014: an intact acceptance (place still exists) is left alone; another user\'s orphan is untouched', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const placeId = seedPlace(trip.id);
const intact = seed(user.id, { state: 'accepted', target: 'place', accepted_place_id: placeId });
const theirs = seed(stranger.id, { state: 'accepted', target: 'place', accepted_place_id: null });
await suggestions.reopenOrphaned(user.id);
expect(rawRow(intact).state).toBe('accepted');
expect(rawRow(theirs).state).toBe('accepted');
});
});
describe('findForUser/findByUserAndVisit (DWS6/DSY3) — full-row parity, including persist(false) shadow FKs', () => {
it('DVSREPO-020: every scalar column round-trips, matching a legacy SELECT *', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const wish = seedBucketItem(user.id);
const id = seed(user.id, {
trip_id: trip.id,
matched_bucket_list_item_id: wish,
accepted_bucket_list_item_id: wish,
target: 'bucket_list',
state: 'accepted',
accepted_hash: 'hash-frozen',
});
const row = await suggestions.findForUser(id, user.id);
expect(row).toEqual(rawRow(id));
// The persist(false) shadow FKs specifically — the trap this Kysely
// full-row read exists to avoid.
expect(row!.trip_id).toBe(trip.id);
expect(row!.matched_bucket_list_item_id).toBe(wish);
expect(row!.accepted_bucket_list_item_id).toBe(wish);
});
it('DVSREPO-021: findForUser is scoped by id AND user_id; findByUserAndVisit by user_id AND source_visit_id', async () => {
const { user } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const id = seed(stranger.id, { source_visit_id: 'abc' });
expect(await suggestions.findForUser(id, user.id)).toBeNull();
expect(await suggestions.findByUserAndVisit(user.id, 'abc')).toBeNull();
expect((await suggestions.findByUserAndVisit(stranger.id, 'abc'))?.id).toBe(id);
});
});
describe('insertVisit/updateNewVisit/refreshHash (DSY4/DSY5/DSY6)', () => {
it('DVSREPO-030: insertVisit writes state=new and every column handed to it', async () => {
const { user } = createUser(testDb);
const id = await suggestions.insertVisit({
user_id: user.id,
source_visit_id: 'v-1',
trip_id: null,
name: 'Stop',
lat: 1,
lng: 2,
started_at: '2026-01-01T00:00:00Z',
ended_at: '2026-01-01T01:00:00Z',
duration_minutes: 60,
local_date: '2026-01-01',
source_status: 'suggested',
confidence: null,
confidence_band: null,
country_code: 'DE',
source_hash: 'h1',
first_seen_at: '2026-01-01T00:00:00Z',
last_seen_at: '2026-01-01T00:00:00Z',
});
expect(rawRow(id)).toMatchObject({ state: 'new', name: 'Stop', country_code: 'DE', source_hash: 'h1' });
});
it('DVSREPO-031: updateNewVisit overwrites the full row and clears source_missing_at', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z', name: 'Old name' });
await suggestions.updateNewVisit(id, {
name: 'New name',
lat: 9,
lng: 9,
started_at: '2026-02-01T00:00:00Z',
ended_at: '2026-02-01T01:00:00Z',
duration_minutes: 30,
local_date: '2026-02-01',
source_status: 'confirmed',
confidence: 0.9,
confidence_band: 'high',
country_code: 'FR',
source_hash: 'h2',
trip_id: trip.id,
last_seen_at: '2026-02-01T00:00:00Z',
});
const row = rawRow(id);
expect(row).toMatchObject({ name: 'New name', source_missing_at: null, trip_id: trip.id, source_hash: 'h2' });
});
it('DVSREPO-032: refreshHash touches only source_hash/source_missing_at/last_seen_at — the user-visible fields survive', async () => {
const { user } = createUser(testDb);
const id = seed(user.id, { name: 'Kept name', state: 'accepted', source_missing_at: '2026-01-01T00:00:00Z' });
await suggestions.refreshHash(id, 'h3', '2026-03-01T00:00:00Z');
expect(rawRow(id)).toMatchObject({ name: 'Kept name', state: 'accepted', source_hash: 'h3', source_missing_at: null, last_seen_at: '2026-03-01T00:00:00Z' });
});
});
describe('listCandidatesForWindow/deleteById/stampMissingIfUnset (DSY7/DSY8/DSY9)', () => {
it('DVSREPO-040: candidates are scoped by user/trip and local_date bounds', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const inWindow = seed(user.id, { trip_id: trip.id, local_date: '2026-05-05' });
seed(user.id, { trip_id: trip.id, local_date: '2019-01-01' });
const candidates = await suggestions.listCandidatesForWindow(user.id, trip.id, '2026-05-01', '2026-05-10');
expect(candidates.map((c) => c.id)).toEqual([inWindow]);
});
it('DVSREPO-041: deleteById removes exactly the named row', async () => {
const { user } = createUser(testDb);
const id = seed(user.id);
await suggestions.deleteById(id);
expect(rawRow(id)).toBeUndefined();
});
it('DVSREPO-042: stampMissingIfUnset preserves the FIRST-missing timestamp (COALESCE, not overwrite)', async () => {
const { user } = createUser(testDb);
const id = seed(user.id, { source_missing_at: '2026-01-01T00:00:00Z' });
await suggestions.stampMissingIfUnset(id, '2026-06-01T00:00:00Z');
expect(rawRow(id).source_missing_at).toBe('2026-01-01T00:00:00Z');
const id2 = seed(user.id, { source_missing_at: null });
await suggestions.stampMissingIfUnset(id2, '2026-06-01T00:00:00Z');
expect(rawRow(id2).source_missing_at).toBe('2026-06-01T00:00:00Z');
});
});
describe('markAccepted (DWS11) — self-column-copy', () => {
it('DVSREPO-050: accepted_hash tracks CURRENT source_hash at UPDATE time, not a value bound earlier', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const placeId = seedPlace(trip.id);
const id = seed(user.id, { source_hash: 'hash-original' });
// A concurrent write lands between "the caller decided to accept" and
// the markAccepted call itself.
testDb.prepare('UPDATE dawarich_visit_suggestions SET source_hash = ? WHERE id = ?').run('hash-concurrent', id);
await suggestions.markAccepted(id, { target: 'place', placeId, journalEntryId: null, bucketItemId: null });
const row = rawRow(id);
expect(row.source_hash).toBe('hash-concurrent');
expect(row.accepted_hash).toBe('hash-concurrent');
expect(row.state).toBe('accepted');
expect(row.accepted_place_id).toBe(placeId);
});
});
describe('listHoldersOfWish/clearWishHolders/assignWishHolder (DSY11/12/13)', () => {
it('DVSREPO-060: listHoldersOfWish excludes the caller\'s own source_visit_id', async () => {
const { user } = createUser(testDb);
const wish = seedBucketItem(user.id);
const holder = seed(user.id, { source_visit_id: 'holder', matched_bucket_list_item_id: wish });
seed(user.id, { source_visit_id: 'self', matched_bucket_list_item_id: wish });
const holders = await suggestions.listHoldersOfWish(user.id, wish, 'self');
expect(holders.map((h) => h.id)).toEqual([holder]);
});
it('DVSREPO-061: clear-then-assign leaves exactly one holder, scoped to the caller', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const wish = seedBucketItem(user.id);
seed(user.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish });
seed(user.id, { source_visit_id: 'b', matched_bucket_list_item_id: wish });
const winnerId = seed(user.id, { source_visit_id: 'c', matched_bucket_list_item_id: null });
const otherHolder = seed(other.id, { source_visit_id: 'a', matched_bucket_list_item_id: wish });
await suggestions.clearWishHolders(user.id, wish);
await suggestions.assignWishHolder(user.id, 'c', wish);
const all = testDb
.prepare('SELECT id, matched_bucket_list_item_id FROM dawarich_visit_suggestions WHERE matched_bucket_list_item_id = ? AND user_id = ?')
.all(wish, user.id) as { id: number; matched_bucket_list_item_id: number }[];
expect(all.map((r) => r.id)).toEqual([winnerId]);
// clearWishHolders is scoped by user_id — another user's row with the
// SAME wish id (a coincidence, wishes aren't globally unique ids across
// users here) is untouched.
expect(rawRow(otherHolder).matched_bucket_list_item_id).toBe(wish);
});
});
describe('matchedStayStart (DWS14)', () => {
it('DVSREPO-070: the most recent started_at among the user\'s stays matched to that wish', async () => {
const { user } = createUser(testDb);
const wish = seedBucketItem(user.id);
seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-01-01T00:00:00Z' });
seed(user.id, { matched_bucket_list_item_id: wish, started_at: '2026-06-01T00:00:00Z' });
expect(await suggestions.matchedStayStart(user.id, wish)).toBe('2026-06-01T00:00:00Z');
expect(await suggestions.matchedStayStart(user.id, 999999)).toBeNull();
});
});
});
@@ -1136,3 +1136,22 @@ describe('PlacesRepository.findChargingProbe (CH1)', () => {
expect(typed).toEqual({ name: 'Just a stop', lat: 48.8566, lng: 2.3522, stop_type: null });
});
});
// ---------------------------------------------------------------------------
// Plan 3h Task 3 (`DawarichSuggestionsService::acceptAsPlace`) — additive.
// ---------------------------------------------------------------------------
describe('PlacesRepository.setSource (DWS7)', () => {
it('DWS7-001: stamps source without touching updated_at, matching the legacy statement\'s own column list', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Imported stay' });
const before = testDb.prepare('SELECT updated_at FROM places WHERE id = ?').get(place.id) as { updated_at: string | null };
await places.setSource(place.id, 'dawarich');
const row = testDb.prepare('SELECT source, updated_at FROM places WHERE id = ?').get(place.id) as { source: string | null; updated_at: string | null };
expect(row.source).toBe('dawarich');
expect(row.updated_at).toBe(before.updated_at);
});
});
@@ -886,3 +886,60 @@ describe('TripsRepository.listOwnedOrMember — AT1 parity with the legacy state
expect(legacy).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Plan 3h Task 3 (`DawarichSyncService`/`DawarichTracksService`) — additive.
// ---------------------------------------------------------------------------
describe('TripsRepository.listTripsToSync (DSY2)', () => {
const LEGACY_LIST_TRIPS_TO_SYNC = `
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
`;
it('TRIPREPO-060: byte-identical to the legacy statement — owned, member-of and archived/undated/out-of-window trips filtered the same way', async () => {
const { user: caller } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const owned = createTrip(testDb, caller.id, { start_date: '2026-01-05', end_date: '2026-01-10' });
const memberOf = createTrip(testDb, stranger.id, { start_date: '2026-02-01', end_date: '2026-02-05' });
addTripMember(testDb, memberOf.id, caller.id);
createTrip(testDb, caller.id, { start_date: '2020-01-01', end_date: '2020-01-02' }); // long past — end_date < -400d
createTrip(testDb, caller.id); // no dates at all — start_date IS NULL
const archived = createTrip(testDb, caller.id, { start_date: '2026-01-05', end_date: '2026-01-10' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
createTrip(testDb, stranger.id, { start_date: '2026-01-05', end_date: '2026-01-10' }); // not the caller's at all
const legacy = testDb.prepare(LEGACY_LIST_TRIPS_TO_SYNC).all(caller.id, caller.id);
const rows = await trips.listTripsToSync(caller.id);
expect(rows).toEqual(legacy);
expect(rows.map((r) => r.id).sort()).toEqual([owned.id, memberOf.id].sort());
});
it('TRIPREPO-061: no syncable trips returns an empty array, matching the legacy statement', async () => {
const { user: caller } = createUser(testDb);
const legacy = testDb.prepare(LEGACY_LIST_TRIPS_TO_SYNC).all(caller.id, caller.id);
expect(await trips.listTripsToSync(caller.id)).toEqual(legacy);
expect(legacy).toEqual([]);
});
});
describe('TripsRepository.findDatesById (DTR1)', () => {
it('TRIPREPO-062: returns start_date/end_date only, matching a legacy narrow SELECT', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2026-05-01', end_date: '2026-05-03' });
const legacy = testDb.prepare('SELECT start_date, end_date FROM trips WHERE id = ?').get(trip.id);
expect(await trips.findDatesById(trip.id)).toEqual(legacy);
});
it('TRIPREPO-063: undefined for a trip that does not exist', async () => {
expect(await trips.findDatesById(999999)).toBeUndefined();
});
});
@@ -14,34 +14,15 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup (real in-memory SQLite — same pattern as the other service tests) ──
const { testDb, dbMock } = vi.hoisted(() => {
const { testDb } = 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: () => {},
// A spy rather than a constant: the acceptance re-reads the place it just
// created so the broadcast carries the `source` mark, and the fallback for
// a re-read that finds nothing is a branch of its own. armStubs() gives it
// the real lookup; one case takes it away again.
getPlaceWithTags: vi.fn(),
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 };
return { testDb: db };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
@@ -55,7 +36,6 @@ 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,
@@ -67,7 +47,15 @@ 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';
import { createTestUnitOfWork, createTestDatabaseService } from '../../helpers/test-uow';
import type { DawarichVisitSuggestionsRepository } from '../../../src/db/repositories/DawarichVisitSuggestions.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { BucketListRepository } from '../../../src/db/repositories/BucketList.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import type { TestOrm } from '../../helpers/test-orm';
import { createTestDawarichVisitSuggestionsRepo } from '../../helpers/dawarich-repos';
import { createTestPlacesRepo, createTestTripsRepo, createTestUnitOfWork, createTestUsersRepo, sharedTestOrm } from '../../helpers/test-uow';
// ── Collaborator stubs ───────────────────────────────────────────────────────
//
@@ -114,20 +102,39 @@ const assignmentsStub = { dayExists: vi.fn(), createAssignment: vi.fn(), broadca
const permissionsStub = { checkPermission: vi.fn() };
const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn() };
let dbs: DatabaseService;
let t: TestOrm;
let suggestions: DawarichVisitSuggestionsRepository;
let trips: TripsRepository;
let placesRepo: PlacesRepository;
let bucketList: BucketListRepository;
let users: UsersRepository;
let svc: DawarichSuggestionsService;
beforeAll(async () => {
dbs = await createTestDatabaseService(testDb);
// Table creation is the OTHER beforeAll below (line-order-later, same
// pre-test phase) — ORM init here does not need the schema to exist yet
// (it only introspects lazily, per query), so the order between these two
// top-level beforeAll hooks is unobservable.
t = await sharedTestOrm(testDb);
suggestions = await createTestDawarichVisitSuggestionsRepo(testDb);
trips = await createTestTripsRepo(testDb);
placesRepo = await createTestPlacesRepo(testDb);
bucketList = t.repo(BucketList);
users = await createTestUsersRepo(testDb);
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, await createTestUnitOfWork(dbs.connection),
);
suggestions,
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,
trips,
placesRepo,
bucketList,
users,
await createTestUnitOfWork(testDb),
);
});
const CREATED_ENTRY_ID = 7702;
@@ -146,13 +153,9 @@ function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng
}
function armStubs(): void {
// The real helper's job, minus the tag join nothing here asserts on: the row
// as it stands after the acceptance stamped `source` on it.
dbMock.getPlaceWithTags
.mockReset()
.mockImplementation(
(placeId: unknown) => testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) ?? null,
);
// `placesRepo.findWithTagsAndRatings` is the REAL repository method now
// (Plan 3h Task 3) — no reset/reimplementation needed by default; only
// DAWARICH-SUG-061 overrides it, locally, with its own `vi.spyOn`.
dawarichStub.getConnection.mockReset().mockReturnValue({ ...CONNECTION });
dawarichStub.getCredentials.mockReset().mockReturnValue({ ...CREDS });
clientStub.findVisitsNear.mockReset();
@@ -306,10 +309,12 @@ beforeEach(() => {
// 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');
t.clear();
armStubs();
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -483,6 +488,36 @@ describe('DawarichSuggestionsService — accepting into a trip', () => {
expect(result.suggestion.sourceChanged).toBe(false);
});
it("DAWARICH-SUG-065: DWS11's self-column-copy — accepted_hash freezes the CURRENT source_hash at UPDATE time, not the stale value accept() read when it started", async () => {
// `markAccepted`'s `accepted_hash = source_hash` has to be a column-ref
// expression (`columnRef`), never a bound parameter: binding would freeze
// whatever `source_hash` held at the moment the repository call was
// BUILT, which is wrong the instant a concurrent write (a sync tick
// racing the same accept-flow) changes `source_hash` before this
// specific UPDATE commits. Proven by mutating the row's `source_hash`
// from INSIDE the same `uow.transactional` block `acceptAsPlace` opens —
// `placesStub.create` fires mid-transaction, strictly before
// `markAccepted` — so this is a genuine "changed between the read and
// the write", not merely "changed before either ran".
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = seedSuggestion({ userId: user.id, tripId: trip.id, sourceHash: 'hash-at-accept-start' });
placesStub.create.mockImplementationOnce((tripId: string, body: { name?: string; lat?: number; lng?: number }) => {
testDb.prepare('UPDATE dawarich_visit_suggestions SET source_hash = ? WHERE id = ?').run('hash-changed-concurrently', id);
return insertPlaceRow(tripId, body);
});
await svc.accept(user.id, id, { target: 'place', tripId: trip.id });
const row = rowOf(id);
expect(row.source_hash).toBe('hash-changed-concurrently');
// A bound-at-read-time literal would have frozen 'hash-at-accept-start'
// here instead — the whole point of the column-ref form.
expect(row.accepted_hash).toBe('hash-changed-concurrently');
expect(row.accepted_hash).not.toBe('hash-at-accept-start');
});
it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', async () => {
const { user } = createUser(testDb);
const id = seedSuggestion({ userId: user.id, tripId: null });
@@ -1293,11 +1328,10 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = seedSuggestion({ userId: user.id, tripId: trip.id });
// Plan 3c Task 0b: `getPlaceWithTags` is `PlacesRepository
// .findWithTagsAndRatings` now (not `db/database.ts`'s deleted free
// function `dbMock.getPlaceWithTags` used to stand in for), so the
// "re-read comes back empty" case is spied directly on `dbs`.
const getPlaceWithTagsSpy = vi.spyOn(dbs, 'getPlaceWithTags').mockResolvedValueOnce(null);
// Plan 3h Task 3: the re-read is `PlacesRepository.findWithTagsAndRatings`
// now, injected directly (no more `DatabaseService.getPlaceWithTags`
// indirection) — spied directly on the real repository instance.
const getPlaceWithTagsSpy = vi.spyOn(placesRepo, 'findWithTagsAndRatings').mockResolvedValueOnce(null);
const result = await svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-3');
getPlaceWithTagsSpy.mockRestore();
@@ -38,7 +38,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
import { DawarichSyncJob } from '../../../src/nest/integrations/dawarich-sync.job';
import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
const SETTING_KEY = 'dawarich_poll_interval_minutes';
@@ -64,22 +64,22 @@ function makeJob(intervalSetting?: string, enabled = true) {
// behaviour exactly, so every existing assertion below is unaffected.
runOnBoot: vi.fn(async (_name: string, fn: () => void | Promise<void>) => { await fn(); }),
};
// Shaped like the real `get<T>(sql, ...params)` rather than a bare `vi.fn()`
// so the stub cannot quietly drift from the signature the job calls; the
// statement and the key are the whole point of DAWARICH-JOB-002.
const db = {
get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })),
// Shaped like the real `AppSettingsRepository#getValue(key)` rather than a
// bare `vi.fn()` so the stub cannot quietly drift from the signature the
// job calls; the key itself is the whole point of DAWARICH-JOB-002.
const appSettings = {
getValue: vi.fn(async (_key: string) => (intervalSetting === undefined ? null : intervalSetting)),
};
const sync = {
runSync: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
syncGloballyEnabled: vi.fn(() => false),
};
const job = new DawarichSyncJob(
db as unknown as DatabaseService,
appSettings as unknown as AppSettingsRepository,
sync as unknown as DawarichSyncService,
registrar as unknown as CronRegistrarService,
);
return { job, registrar, db, sync, takeTick: () => onTick };
return { job, registrar, db: appSettings, sync, takeTick: () => onTick };
}
beforeEach(() => vi.clearAllMocks());
@@ -98,7 +98,7 @@ describe('DawarichSyncJob bootstrap', () => {
// other integration's cadence and nothing anywhere would fail.
const { job, db } = makeJob('20');
await job.onApplicationBootstrap();
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY);
expect(db.getValue).toHaveBeenCalledWith(SETTING_KEY);
});
it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', async () => {
@@ -133,7 +133,7 @@ describe('DawarichSyncJob bootstrap', () => {
const { job, registrar, db } = makeJob('15', false);
await job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
expect(db.getValue).not.toHaveBeenCalled();
expect(logMock.logInfo).not.toHaveBeenCalled();
});
@@ -154,7 +154,7 @@ describe('DawarichSyncJob bootstrap', () => {
const { job, db, registrar, takeTick } = makeJob('15');
await job.onApplicationBootstrap();
await takeTick()?.();
expect(db.get).toHaveBeenCalledTimes(1);
expect(db.getValue).toHaveBeenCalledTimes(1);
expect(registrar.register).toHaveBeenCalledTimes(1);
expect(registrar.unregister).not.toHaveBeenCalled();
});
@@ -164,7 +164,7 @@ describe('DawarichSyncJob bootstrap', () => {
registrar.runOnBoot.mockImplementationOnce(async () => { /* simulates no ORM available — fn never runs */ });
await job.onApplicationBootstrap();
expect(registrar.runOnBoot).toHaveBeenCalledWith('dawarich-sync-boot', expect.any(Function));
expect(db.get).not.toHaveBeenCalled();
expect(db.getValue).not.toHaveBeenCalled();
// minutes keeps its 15-default (the interval read never ran) and the job
// still registers, rather than never scheduling at all.
expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/15 * * * *', expect.any(Function));
@@ -48,7 +48,14 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { createTestAddonsService } from '../../helpers/test-addons';
import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import { BucketList } from '../../../src/db/entities/BucketList.entity';
import type { BucketListRepository } from '../../../src/db/repositories/BucketList.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { DawarichVisitSuggestionsRepository } from '../../../src/db/repositories/DawarichVisitSuggestions.repository';
import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository';
import type { TestOrm } from '../../helpers/test-orm';
import { createTestDawarichConnectionsRepo, createTestDawarichVisitSuggestionsRepo } from '../../helpers/dawarich-repos';
import { createTestTripsRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
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';
@@ -113,6 +120,11 @@ const dawarich = {
// Direct construction over the shared test connection — no TestingModule
// (repo convention for DI-native service unit tests).
const dbs = new DatabaseService(testDb);
let t: TestOrm;
let suggestions: DawarichVisitSuggestionsRepository;
let trips: TripsRepository;
let bucketList: BucketListRepository;
let connections: DawarichConnectionsRepository;
let addons: AddonsService;
let svc: DawarichSyncService;
@@ -227,8 +239,22 @@ function withVisits(...visits: DawarichVisitRaw[]): void {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await sharedTestOrm(testDb);
suggestions = await createTestDawarichVisitSuggestionsRepo(testDb);
trips = await createTestTripsRepo(testDb);
bucketList = t.repo(BucketList);
connections = await createTestDawarichConnectionsRepo(testDb);
addons = await createTestAddonsService(testDb, dbs);
svc = new DawarichSyncService(dbs, addons, client, dawarich, await createTestUnitOfWork(dbs.connection));
svc = new DawarichSyncService(
suggestions,
addons,
client,
dawarich,
trips,
bucketList,
connections,
await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
@@ -238,6 +264,7 @@ beforeEach(() => {
// otherwise outlive their user and leak into the next case.
testDb.exec('DELETE FROM dawarich_visit_suggestions');
testDb.exec('DELETE FROM dawarich_connections');
t.clear();
vi.clearAllMocks();
probeCapabilities.mockResolvedValue(CAPABILITIES);
@@ -247,7 +274,8 @@ beforeEach(() => {
connect(USER);
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -787,6 +815,43 @@ describe('DawarichSyncService — bucket-list matching', () => {
expect(all.find((r) => r.source_visit_id === '990')!.matched_bucket_list_item_id).toBeNull();
});
it('DAWARICH-SYNC-079: DSY12/DSY13 clear-then-set ordering — two rows already (wrongly) claiming the same wish end with exactly one holder, the new winner, never two or zero', async () => {
// A state that should not arise from ordinary matching (claimWish already
// clears every previous holder before assigning), but the ordering proof
// has to hold even from a seeded, already-inconsistent starting point:
// DSY12 (clear every existing holder) must run and complete BEFORE DSY13
// (assign the new one), in the SAME transaction — reversed, or run as two
// independent statements, a concurrent read between them could observe
// either two holders or zero.
const wish = bucketItem('Brandenburger Tor', LAT, LNG);
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, matched_bucket_list_item_id)
VALUES (?, '970', ?, 'Old holder A', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 60,
'2019-01-01', 'suggested', 'new', 'deadbeef-a', ?)`,
)
.run(USER, TRIP, LAT, LNG, wish);
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, matched_bucket_list_item_id)
VALUES (?, '971', ?, 'Old holder B', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 60,
'2019-01-01', 'suggested', 'new', 'deadbeef-b', ?)`,
)
.run(USER, TRIP, LAT, LNG, wish);
withVisits(visit({ id: 972 }));
await svc.syncUser(USER);
const all = rows();
const holders = all.filter((r) => r.matched_bucket_list_item_id === wish);
expect(holders).toHaveLength(1);
expect(holders[0]!.source_visit_id).toBe('972');
});
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);
@@ -53,7 +53,7 @@ import type {
DawarichTrackFeature,
} from '../../../src/nest/integrations/dawarich.client';
import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { DawarichCapabilities } from '@trek/shared';
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -138,10 +138,10 @@ interface Collaborators {
}
function harness(over: Collaborators = {}) {
const canAccessTrip = vi.fn().mockResolvedValue('access' in over ? over.access : TRIP_ACCESS);
const get = vi
const findAccessible = vi.fn().mockResolvedValue('access' in over ? over.access : TRIP_ACCESS);
const findDatesById = vi
.fn()
.mockReturnValue(
.mockResolvedValue(
'trip' in over ? over.trip : { start_date: '2026-05-01', end_date: '2026-05-03' },
);
const getCredentials = vi.fn().mockReturnValue('creds' in over ? over.creds : CREDS);
@@ -150,12 +150,12 @@ function harness(over: Collaborators = {}) {
const listPoints = vi.fn().mockResolvedValue({ points: [], truncated: false });
const service = new DawarichTracksService(
{ canAccessTrip, get } as unknown as DatabaseService,
{ findAccessible, findDatesById } as unknown as TripsRepository,
{ getCredentials, getCapabilities } as unknown as DawarichService,
{ listTracks, listPoints } as unknown as DawarichClient,
);
return { service, canAccessTrip, get, getCredentials, getCapabilities, listTracks, listPoints };
return { service, canAccessTrip: findAccessible, get: findDatesById, getCredentials, getCapabilities, listTracks, listPoints };
}
beforeEach(() => {
+21 -24
View File
@@ -44,26 +44,15 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
// ── DB setup (real in-memory SQLite, the pattern the other Dawarich service tests use) ──
const { testDb, dbMock } = vi.hoisted(() => {
const { testDb } = 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,
},
};
return { testDb: db };
});
vi.mock('../../../src/db/database', () => dbMock);
// Same fixed key the global setup exports, pinned here so the at-rest round
// trip cannot depend on what is lying in server/data.
vi.mock('../../../src/config', () => ({
@@ -91,9 +80,11 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import type { DawarichConnectionsRepository } from '../../../src/db/repositories/DawarichConnections.repository';
import { DawarichService } from '../../../src/nest/integrations/dawarich.service';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import type { TestOrm } from '../../helpers/test-orm';
import { createTestDawarichConnectionsRepo } from '../../helpers/dawarich-repos';
import { createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import {
DawarichError,
type DawarichClient,
@@ -114,7 +105,8 @@ const client = {
findVisitsNear: vi.fn(),
};
const dbs = new DatabaseService(testDb);
let t: TestOrm;
let connections: DawarichConnectionsRepository;
let svc: DawarichService;
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -255,23 +247,27 @@ const countryCodeAbsentCases: Array<[string, Partial<DawarichVisitRaw>]> = [
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
t = await sharedTestOrm(testDb);
connections = await createTestDawarichConnectionsRepo(testDb);
svc = new DawarichService(
dbs,
connections,
audit as unknown as AuditService,
client as unknown as DawarichClient,
await createTestUnitOfWork(dbs.connection),
await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
vi.clearAllMocks();
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: IP });
allEndpointsAnswer();
USER = createUser(testDb).user.id;
});
afterAll(() => {
afterAll(async () => {
await t.close();
testDb.close();
});
@@ -759,11 +755,12 @@ describe('DawarichService saveSettings', () => {
it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => {
connect(USER, { url: 'https://old.example', apiKey: 'stored-key' });
const realRun = dbs.run.bind(dbs);
const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
if (sql.includes('SET api_key = ?')) throw new Error('disk I/O error');
return realRun(sql, ...params);
});
// Rollback proof (R7's transaction, one of the plan's eight): spy the
// SECOND write (the key) to throw and confirm the FIRST write (the
// address, already committed to the transactional connection) is rolled
// back with it — proving `saveSettings`'s `uow.transactional` wraps both,
// not just the address upsert.
const spy = vi.spyOn(connections, 'setApiKey').mockRejectedValue(new Error('disk I/O error'));
try {
await expect(svc.saveSettings(USER, 'https://new.example', 'fresh', false, true, IP)).rejects.toThrow(