diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts index 64f19d54b..2545afe33 100644 --- a/server/src/db/dialect/sql-functions.ts +++ b/server/src/db/dialect/sql-functions.ts @@ -1,6 +1,6 @@ import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core'; import { SqlitePlatform } from '@mikro-orm/sql'; -import type { Expression, ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool, StringReference } from 'kysely'; +import { sql, type Expression, type ExpressionBuilder, type ExpressionWrapper, type RawBuilder, type ReferenceExpression, type SqlBool, type StringReference } from 'kysely'; /** * The only place a repository may spell a database function. @@ -230,6 +230,30 @@ export function coalesceParam(platform: Platform, ref: string, value: string | n return unsupported(platform); } +/** + * `COALESCE(?, )` — the MIRROR-IMAGE value-side shape of + * {@link coalesceParam} above: the bound value goes FIRST, the column is + * the fallback. NOT interchangeable with `coalesceParam` — that one keeps + * the EXISTING column unless it is NULL (`places.service.ts`'s own legacy + * shape); this one prefers the NEW value unless IT is null, keeping the + * existing column only then. `doc-sync.service.ts`'s DS23/DS24 + * (`upsertItem`'s `file_id=COALESCE(?,file_id)`, + * `remote_id=COALESCE(?,remote_id)`, … eight columns, both the plain + * `nativeUpdate` and the Kysely `ON CONFLICT` forms) is the shape this + * exists for — confirmed genuinely the opposite direction from + * `coalesceParam`'s own doc'd precedent by reading the legacy SQL text + * literally, not assumed from the function name alone (a same-named + * "coalesce" helper reused in the wrong direction silently drops every + * incoming update — verified directly against a live row: a `recordAttempt` + * call passing a genuinely new `remote_version`/`content_sha256` left both + * columns exactly as they were before, since `coalesceParam`'s `COALESCE(col, + * ?)` always preferred the non-null existing value). + */ +export function coalesceOverride(platform: Platform, value: string | number | null, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(?, ${column(ref)})`, [value]); + return unsupported(platform); +} + /** * `ABS( - ?)`, for use as a filter KEY (the `'string'`-hint coercion * `lower()` above relies on) so it composes with `$lte`/`$gte`: @@ -871,3 +895,60 @@ export function nowPlusSecondsKysely( return unsupported(platform); } +// --------------------------------------------------------------------------- +// Plan 3h Task 5 (doc-sync) — `FOUND_AGAIN` (`doc-sync.service.ts:53-54`), the +// module-level SQL-text CASE constant reused by four legacy UPDATEs (DS2 +// `relocate`, DS3 `rename_remote`, DS5 `rename_local`, DS12 `touch`). Unlike +// every other CASE in this file's DS2-DS12 neighbourhood — whose WHEN +// condition binds a value already known in JS at the call site (a boolean +// flag, a state string), so the whole CASE collapses to a plain JS ternary +// deciding which field to include in a `nativeUpdate` call — this one +// compares the ROW'S OWN CURRENT `state`/`file_id` columns, which are not +// knowable until the UPDATE executes. That is genuinely SQL-level, and the +// four call sites share one method (`DocumentSyncItemsRepository`'s private +// `foundAgain` builder) rather than four independent re-derivations, per the +// task's own instruction to keep `FOUND_AGAIN` "a reusable typed CASE +// expression ... consumed by all four". +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN = 'remote_missing' AND IS NOT NULL THEN + * 'synced' ELSE END` — doc-sync's `FOUND_AGAIN` clause, for the + * `state` column of an UPDATE `document_sync_items` SET list. Only a copy + * that is BOTH on record as missing AND still paired to a file recovers this + * way; every other state (`error`, `conflict`, a row with no file at all) + * passes through unchanged. `remote_missing_at` is cleared unconditionally + * by the SAME UPDATE's own `remote_missing_at = NULL` (a plain value, not + * part of this fragment — the caller sets it alongside). + */ +export function foundAgainState(platform: Platform, stateRef: string, fileIdRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw( + `CASE WHEN ${column(stateRef)} = 'remote_missing' AND ${column(fileIdRef)} IS NOT NULL THEN 'synced' ELSE ${column(stateRef)} END`, + ); + } + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link currentTimestamp}: the bare + * `CURRENT_TIMESTAMP` keyword, for a value position inside a hand-typed + * Kysely statement (DS24's `ON CONFLICT ... DO UPDATE SET last_seen_at = + * CURRENT_TIMESTAMP` — R3's partial-index upsert). Verified directly against + * `better-sqlite3` that SQLite accepts the bare keyword (`SELECT + * CURRENT_TIMESTAMP`) but rejects it called as a function + * (`SELECT CURRENT_TIMESTAMP()` → `near "(": syntax error`), so + * `eb.fn('current_timestamp', [])` — every other helper in this file's + * usual Kysely shape — is not an option here; the whole point of a keyword + * literal is that it takes no parentheses. This is this file's one + * Kysely-side use of the `sql` template tag — confined here, the sanctioned + * escape hatch this file already is for every raw-SQL spelling in the + * codebase (the MikroORM helpers above use `raw()` for the identical + * reason), never inline in a repository (the TRAP list's "Kysely `sql` + * banned under repositories" is about repository FILES, not this one). + */ +export function currentTimestampKysely(platform: Platform): RawBuilder { + if (platform instanceof SqlitePlatform) return sql`CURRENT_TIMESTAMP`; + return unsupported(platform); +} + diff --git a/server/src/db/repositories/DocumentConnections.repository.ts b/server/src/db/repositories/DocumentConnections.repository.ts index 593ee6111..1df1b8333 100644 --- a/server/src/db/repositories/DocumentConnections.repository.ts +++ b/server/src/db/repositories/DocumentConnections.repository.ts @@ -1,4 +1,195 @@ +import { currentTimestamp, coalesceOverride } from '../dialect/sql-functions'; import type { DocumentConnections } from '../entities/DocumentConnections.entity'; +import { type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; -export class DocumentConnectionsRepository extends TrekRepository {} +/** + * A bare `document_connections` row. `secrets` is opaque encrypted TEXT here + * and everywhere in this repository (R7): encryption/decryption stays in + * `DocSyncConfigService`/`doc-sync-secrets.ts`, never in this file — a + * repository method takes and returns the ciphertext exactly as stored, + * calling no crypto function of its own. + */ +export interface DocumentConnectionRow { + id: number; + trip_id: number; + provider_id: string; + owner_user_id: number; + base_url: string; + secrets: string | null; + settings: string; + allow_insecure_tls: number; + capabilities: string | null; + last_probe_at: string | null; + last_probe_state: string; + last_probe_error: string | null; + created_at: string | null; + updated_at: string | null; +} + +const _connectionRowKeys: AssertRowKeys = true; + +interface OwnerLeftKyselyDB { + document_connections: { id: number; trip_id: number; owner_user_id: number }; + trip_members: { trip_id: number; user_id: number }; + trips: { id: number; user_id: number }; +} + +/** + * `document_connections` — one provider credential per trip (R7's encrypted + * `secrets` column; see its class docstring for the encrypt-in-service rule). + */ +export class DocumentConnectionsRepository extends TrekRepository { + /** DSC4 (`getConnection`) — `SELECT * FROM document_connections WHERE id = ?`. */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? toRow(row) : undefined; + } + + /** DSC5 (`listConnections`) — `SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id`. */ + async listForTrip(tripId: number): Promise { + const rows = await this.find({ trip_id: tripId }, { orderBy: { id: 'asc' } }); + return rows.map(toRow); + } + + /** DSC7 (`upsertConnection`'s pre-check) — `SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?`. */ + async findForTripAndProvider(tripId: number, providerId: string): Promise { + const row = await this.findOne({ trip_id: tripId, provider_id: providerId }); + return row ? toRow(row) : undefined; + } + + /** + * DSC6 (`saveEarnedSecret`, inside `uow.transactional`) — `UPDATE + * document_connections SET secrets = ? WHERE id = ?`. `secrets` arrives + * already encrypted — this method never calls `encryptSecrets` itself. + */ + async updateSecrets(id: number, secrets: string | null): Promise { + await this.nativeUpdate({ id }, { secrets }); + } + + /** + * DSC8 (`upsertConnection`, existing row, inside `uow.transactional`) — + * `UPDATE document_connections SET base_url = ?, secrets = ?, settings = + * ?, allow_insecure_tls = ?, owner_user_id = ?, updated_at = + * CURRENT_TIMESTAMP WHERE id = ?`. + */ + async updateConnection( + id: number, + data: { base_url: string; secrets: string | null; settings: string; allow_insecure_tls: number; owner_user_id: number }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + base_url: data.base_url, + secrets: data.secrets, + settings: data.settings, + allow_insecure_tls: data.allow_insecure_tls, + ownerUser: data.owner_user_id, + updated_at: currentTimestamp(platform), + }, + ); + } + + /** + * DSC9 (`upsertConnection`, new row, inside `uow.transactional`) — + * `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, + * base_url, secrets, settings, allow_insecure_tls) VALUES (?×7)`. + */ + async insertConnection(data: { + trip_id: number; + provider_id: string; + owner_user_id: number; + base_url: string; + secrets: string | null; + settings: string; + allow_insecure_tls: number; + }): Promise { + return await this.insert({ + trip: data.trip_id, + provider: data.provider_id, + ownerUser: data.owner_user_id, + base_url: data.base_url, + secrets: data.secrets, + settings: data.settings, + allow_insecure_tls: data.allow_insecure_tls, + }); + } + + /** + * DSC10 (`recordProbe`) — `UPDATE document_connections SET last_probe_at = + * CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?, + * capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP + * WHERE id = ?`. + */ + async recordProbe(id: number, state: string, error: string | null, capabilitiesJson: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + last_probe_at: currentTimestamp(platform), + last_probe_state: state, + last_probe_error: error, + capabilities: coalesceOverride(platform, capabilitiesJson, 'capabilities'), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** DSC11 (`deleteConnection`) — `DELETE FROM document_connections WHERE id = ?`. Its bindings cascade (ON DELETE CASCADE). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSC20/DSC21's shared predicate — "which connections' credential owner is + * no longer on the connection's trip (neither via `trip_members` nor as + * the trip's own owner)". The legacy code re-implemented this `NOT IN + * (... UNION ...)` subquery inline at BOTH call sites + * (`ownerLeft`'s single-connection check, `markOrphanedLinks`'s bulk + * sweep); this is the ONE typed version both now consume — + * `DocSyncConfigService.ownerLeft(id)` checks membership of this list, + * `.markOrphanedLinks()` hands the whole list to + * `TripDocumentLinksRepository.markOrphanedByConnectionIds`. `UNION` (not + * `UNION ALL`) — the legacy text dedupes, matched exactly: Kysely's + * `.union()`, not `.unionAll()`. + */ + async listOrphanedIds(): Promise { + const rows = await this.kysely() + .selectFrom('document_connections as c') + .select('c.id') + .where((eb) => + eb( + 'c.owner_user_id', + 'not in', + (qb) => + qb + .selectFrom('trip_members as tm') + .select('tm.user_id') + .whereRef('tm.trip_id', '=', 'c.trip_id') + .union(qb.selectFrom('trips as t').select('t.user_id').whereRef('t.id', '=', 'c.trip_id')), + ), + ) + .execute(); + return rows.map((r) => r.id); + } +} + +function toRow(entity: DocumentConnections): DocumentConnectionRow { + return { + id: entity.id, + trip_id: entity.trip_id, + provider_id: entity.provider_id, + owner_user_id: entity.owner_user_id, + base_url: entity.base_url, + secrets: entity.secrets ?? null, + settings: entity.settings, + allow_insecure_tls: entity.allow_insecure_tls, + capabilities: entity.capabilities ?? null, + last_probe_at: entity.last_probe_at ?? null, + last_probe_state: entity.last_probe_state, + last_probe_error: entity.last_probe_error ?? null, + created_at: entity.created_at ?? null, + updated_at: entity.updated_at ?? null, + }; +} diff --git a/server/src/db/repositories/DocumentProviderFields.repository.ts b/server/src/db/repositories/DocumentProviderFields.repository.ts index 18903ab52..307275dde 100644 --- a/server/src/db/repositories/DocumentProviderFields.repository.ts +++ b/server/src/db/repositories/DocumentProviderFields.repository.ts @@ -1,4 +1,57 @@ import type { DocumentProviderFields } from '../entities/DocumentProviderFields.entity'; +import { type AssertRowKeys } from './_shared/rows'; import { TrekRepository } from './_shared/trek-repository'; -export class DocumentProviderFieldsRepository extends TrekRepository {} +/** DSC2's projection — every column the connection form needs, minus `id`/`provider_id`. */ +export interface DocumentProviderFieldRow { + field_key: string; + /** i18n key suffix, never display text: the client resolves `docsync.