From 1d72c6909bf2774173246571cd1d4113b6d1e79c Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 23 Sep 2026 23:55:52 +0200 Subject: [PATCH] refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories --- .../db/repositories/BucketList.repository.ts | 105 +++++ .../DawarichConnections.repository.ts | 199 ++++++++- .../DawarichVisitSuggestions.repository.ts | 397 ++++++++++++++++- .../src/db/repositories/Places.repository.ts | 194 ++++++++ .../src/db/repositories/Trips.repository.ts | 43 +- .../dawarich-suggestions.service.ts | 186 ++------ .../nest/integrations/dawarich-sync.job.ts | 24 +- .../integrations/dawarich-sync.service.ts | 194 +++----- .../integrations/dawarich-tracks.service.ts | 13 +- .../src/nest/integrations/dawarich.module.ts | 21 + .../src/nest/integrations/dawarich.service.ts | 94 +--- server/tests/helpers/dawarich-repos.ts | 40 ++ .../DawarichConnections.repository.test.ts | 250 +++++++++++ ...awarichVisitSuggestions.repository.test.ts | 421 ++++++++++++++++++ .../db/repositories/Places.repository.test.ts | 19 + .../db/repositories/Trips.repository.test.ts | 57 +++ .../nest/dawarich-suggestions.service.test.ts | 128 ++++-- .../tests/unit/nest/dawarich-sync.job.test.ts | 24 +- .../unit/nest/dawarich-sync.service.test.ts | 71 ++- .../unit/nest/dawarich-tracks.service.test.ts | 12 +- .../tests/unit/nest/dawarich.service.test.ts | 45 +- 21 files changed, 2068 insertions(+), 469 deletions(-) create mode 100644 server/tests/helpers/dawarich-repos.ts create mode 100644 server/tests/unit/db/repositories/DawarichConnections.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts diff --git a/server/src/db/repositories/BucketList.repository.ts b/server/src/db/repositories/BucketList.repository.ts index 29fd36b8f..b47fd2931 100644 --- a/server/src/db/repositories/BucketList.repository.ts +++ b/server/src/db/repositories/BucketList.repository.ts @@ -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 { return this.kysely(); } + private visitDb() { + return this.kysely(); + } + /** AT30 (`bucketList`) — `SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC`. */ async listForUser(userId: number): Promise { 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 { async deleteForUser(id: number | string, userId: number): Promise { 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 { + 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 { + 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 { + 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(); + } } diff --git a/server/src/db/repositories/DawarichConnections.repository.ts b/server/src/db/repositories/DawarichConnections.repository.ts index 9e6bcea1b..2dbb381c0 100644 --- a/server/src/db/repositories/DawarichConnections.repository.ts +++ b/server/src/db/repositories/DawarichConnections.repository.ts @@ -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 {} +/** + * `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 = 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 { + /** + * 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 { + 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('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts index ba6f33ac4..84ed763ce 100644 --- a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts +++ b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts @@ -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 {} +/** `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 = 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 { + private joinedQuery() { + return this.kysely() + .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 { + 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 { + const result = await this.kysely() + .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 { + 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 { + 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 { + const row = await this.kysely() + .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 { + const row = await this.kysely() + .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 { + 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 { + 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 { + 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 { + 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('all', false); + } + + /** DSY8 — `flagMissing`'s delete branch (only unseen `'new'`-state rows): `DELETE FROM dawarich_visit_suggestions WHERE id = ?`. */ + async deleteById(id: number): Promise { + 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 { + 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 { + 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 { + 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('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 { + 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 { + 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 { + 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; + } +} diff --git a/server/src/db/repositories/Places.repository.ts b/server/src/db/repositories/Places.repository.ts index f3d289f6a..8fa990f48 100644 --- a/server/src/db/repositories/Places.repository.ts +++ b/server/src/db/repositories/Places.repository.ts @@ -988,6 +988,200 @@ export class PlacesRepository extends TrekRepository { .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 { + 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 { + return await this.kysely() + .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('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 { + 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('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[]> { + 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[]>('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 { + const result = await this.kysely().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. */ diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts index 0b8286036..dca0c7a2c 100644 --- a/server/src/db/repositories/Trips.repository.ts +++ b/server/src/db/repositories/Trips.repository.ts @@ -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 { .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. */ diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts index 3b2651764..183005bfc 100644 --- a/server/src/nest/integrations/dawarich-suggestions.service.ts +++ b/server/src/nest/integrations/dawarich-suggestions.service.ts @@ -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 { 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( - `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 { - 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 { - const row = this.db.get( - `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name - FROM dawarich_visit_suggestions s - LEFT JOIN trips t ON t.id = s.trip_id - LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id - WHERE s.id = ? AND s.user_id = ?`, - id, - userId, - ); + 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 { - 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 { - const row = this.db.get( - '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 { - 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 { - 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( - `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 { - 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 { - 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; diff --git a/server/src/nest/integrations/dawarich-sync.job.ts b/server/src/nest/integrations/dawarich-sync.job.ts index 74741897a..aa9ef7920 100644 --- a/server/src/nest/integrations/dawarich-sync.job.ts +++ b/server/src/nest/integrations/dawarich-sync.job.ts @@ -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 { 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`); diff --git a/server/src/nest/integrations/dawarich-sync.service.ts b/server/src/nest/integrations/dawarich-sync.service.ts index 66cde7f82..ab7b7311f 100644 --- a/server/src/nest/integrations/dawarich-sync.service.ts +++ b/server/src/nest/integrations/dawarich-sync.service.ts @@ -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(); 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 { - return this.db.all( - `SELECT DISTINCT t.id, t.start_date, t.end_date - FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE (t.user_id = ? OR m.user_id IS NOT NULL) - AND COALESCE(t.is_archived, 0) = 0 - AND t.start_date IS NOT NULL - AND (t.end_date IS NULL OR t.end_date >= date('now', '-400 days')) - AND t.start_date <= date('now', '+1 day') - ORDER BY t.start_date DESC`, - userId, - userId, - ); + 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( - '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 { - 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? * diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts index 840ff9875..ff6ecdc8b 100644 --- a/server/src/nest/integrations/dawarich-tracks.service.ts +++ b/server/src/nest/integrations/dawarich-tracks.service.ts @@ -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 { - 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; diff --git a/server/src/nest/integrations/dawarich.module.ts b/server/src/nest/integrations/dawarich.module.ts index c416defab..89e800c6e 100644 --- a/server/src/nest/integrations/dawarich.module.ts +++ b/server/src/nest/integrations/dawarich.module.ts @@ -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 diff --git a/server/src/nest/integrations/dawarich.service.ts b/server/src/nest/integrations/dawarich.service.ts index 422556e6a..145cc20bd 100644 --- a/server/src/nest/integrations/dawarich.service.ts +++ b/server/src/nest/integrations/dawarich.service.ts @@ -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 { - return this.db.get( - `SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled, - last_sync_at, last_sync_state, last_sync_error, capabilities - FROM dawarich_connections WHERE user_id = ?`, - userId, - ); + private async readRow(userId: number): Promise { + 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 { - 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 { - 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 { - 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 { - 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 diff --git a/server/tests/helpers/dawarich-repos.ts b/server/tests/helpers/dawarich-repos.ts new file mode 100644 index 000000000..32334c2cb --- /dev/null +++ b/server/tests/helpers/dawarich-repos.ts @@ -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>(); +const suggestionsPerHandle = new WeakMap>(); + +/** The `DawarichConnectionsRepository` a hand-constructed `DawarichService`/`DawarichSyncService` needs. */ +export function createTestDawarichConnectionsRepo(db: Database.Database): Promise { + 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 { + 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; +} diff --git a/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts b/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts new file mode 100644 index 000000000..b591bc91b --- /dev/null +++ b/server/tests/unit/db/repositories/DawarichConnections.repository.test.ts @@ -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 | undefined { + return testDb.prepare('SELECT * FROM dawarich_connections WHERE user_id = ?').get(userId) as Record | 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'); + }); + }); +}); diff --git a/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts b/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts new file mode 100644 index 000000000..60d1b592b --- /dev/null +++ b/server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts @@ -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 { + return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as Record; +} + +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(); + }); + }); +}); diff --git a/server/tests/unit/db/repositories/Places.repository.test.ts b/server/tests/unit/db/repositories/Places.repository.test.ts index 1450e38bd..d0595538b 100644 --- a/server/tests/unit/db/repositories/Places.repository.test.ts +++ b/server/tests/unit/db/repositories/Places.repository.test.ts @@ -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); + }); +}); diff --git a/server/tests/unit/db/repositories/Trips.repository.test.ts b/server/tests/unit/db/repositories/Trips.repository.test.ts index ff762c2e9..fde840477 100644 --- a/server/tests/unit/db/repositories/Trips.repository.test.ts +++ b/server/tests/unit/db/repositories/Trips.repository.test.ts @@ -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(); + }); +}); diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index fca7f07e0..1852f4ef2 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -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(); diff --git a/server/tests/unit/nest/dawarich-sync.job.test.ts b/server/tests/unit/nest/dawarich-sync.job.test.ts index 6bd034bd6..6e70f583c 100644 --- a/server/tests/unit/nest/dawarich-sync.job.test.ts +++ b/server/tests/unit/nest/dawarich-sync.job.test.ts @@ -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) => { await fn(); }), }; - // Shaped like the real `get(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>().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)); diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts index 3110a520f..35912c0c5 100644 --- a/server/tests/unit/nest/dawarich-sync.service.test.ts +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -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); diff --git a/server/tests/unit/nest/dawarich-tracks.service.test.ts b/server/tests/unit/nest/dawarich-tracks.service.test.ts index b953b9d77..c3df53156 100644 --- a/server/tests/unit/nest/dawarich-tracks.service.test.ts +++ b/server/tests/unit/nest/dawarich-tracks.service.test.ts @@ -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(() => { diff --git a/server/tests/unit/nest/dawarich.service.test.ts b/server/tests/unit/nest/dawarich.service.test.ts index 11076a73e..b5a7a1317 100644 --- a/server/tests/unit/nest/dawarich.service.test.ts +++ b/server/tests/unit/nest/dawarich.service.test.ts @@ -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]> = [ 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(