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