From de8caa81a2967c6510455b10736be6981f65ff6f Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:27:30 +0200 Subject: [PATCH] Feat/orm : mikroorm (#2416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * base entities * update deps * fix date as string * rename entities * migrations + seeders * feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text * feat(db): discover entities from an explicit list instead of a glob * test(db): createTestOrm binds an ORM to a suite's own SQLite handle * feat(db): dialect helper is the one place SQL functions are spelled * test(db): dialect helper refuses malformed column references and day counts * feat(db): snake_case entity shape and the first three repositories (Days, DayNotes, Trips) * fix(db): repositories mirror the statement's column set and guard their row types * feat(db): request-context and unit-of-work helpers for the ORM, with concurrency tests * test(server): integration suites boot on the migrated schema snapshot * test(server): snapshot harness keeps the legacy id semantics * fix(test): route MikroORM's dynamic imports through vitest from the setup file, not the ORM config * test(db): coverage ratchet entries for the ORM foundation * test(server): websocket suites boot on the migrated schema snapshot * test(server): coverage's uncovered-file pass goes through the SWC transform * fix(db): dialect helpers dispatch on the platform; lint gate for the repository layer * test(server): ORM suites open the schema snapshot; RESET_TABLES derived from the schema * chore(server): promise safety net and sweep tooling for the async migration * refactor(realtime, systemNotices, addons, airports, audit, categories, permissions, query-helpers, tags, transit, trip-membership): async DB-touching methods; transactions through UnitOfWork * fix(server): observe every promise the async sweep left unobserved (timer, handshake, plugin-runtime test) * test(doc-sync): unit suites await the methods the async sweep made async * refactor(atlas, school-holidays, settings): async DB-touching methods; transactions through UnitOfWork * chore(server): lint rule refusing a Promise used as a plain value, and the misses it found * test(server): suites await the methods Task 1's forced edits made async * refactor(budget, common, day-notes, days): async DB-touching methods; transactions through UnitOfWork * test(oauth): controller suite awaits the async service * chore(server): db-call-graph --unawaited lists calls to async methods whose result is used without await * refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork * refactor(plugins): async DB-touching methods; transactions through UnitOfWork * chore(server): db-call-graph --sync stops its transitive walk at an async callee * chore(plugins): drop gate-only asyncs; observe the scheduler's rejection; async event-sink stub * refactor(notifications, place-photos, backup): async DB-touching methods; transactions through UnitOfWork * refactor(auth, files): async DB-touching methods; transactions through UnitOfWork * test(server): suites await the methods earlier sweep groups made async * refactor(tokens): async DB-touching methods; the auth token handlers await them * test(files): upload filters fail closed on an excluded or unresolvable type * refactor(collab, collections, maps): async DB-touching methods; transactions through UnitOfWork * refactor(memories, photos, packing, share): async DB-touching methods; transactions through UnitOfWork * chore(server): lint rule also refuses a Promise handed to expect() without resolves/rejects * refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork * fix(vacay): type the loop accumulators the async sweep introduced * refactor(journey, oidc, place-enrichment, tokens, trip-members): async DB-touching methods; transactions through UnitOfWork * fix(server): the journey hooks are awaited by their callers, not detached * refactor(assignments, public-api, accommodations, places): async DB-touching methods; transactions through UnitOfWork * refactor(trips): rewrite AccommodationsService.createAccommodation .bind alias Recipe R4 trap named in the Task 12 brief: a .bind alias of an AssignmentsService/AccommodationsService method is typed any and hides a missing await from tsc and the promise lint rules. createAccommodation belongs to this task's group, so rewritten as an arrow forwarding to the method instead of a bound reference. * refactor(assignments): drop the unused Trip type alias * refactor(reservations, booking-import, calendar, integrations, roadtrip): async DB-touching methods; transactions through UnitOfWork * refactor(roadtrip, reservations): return await on transactional; document the single-flight promises * refactor(trip-read-model, trips, feeds): async DB-touching methods; transactions through UnitOfWork * refactor(server): remove DatabaseService.transaction — every transaction runs through UnitOfWork * refactor(trips, feeds): drop added return annotations; no double await on the feed guard * fix(server): phase 1 review follow-ups — awaited returns, logged upload-filter rejections, covered handshake/notice branches, call-graph gate in CI, dead test imports * fix(server): upload filters answer multer once on a throwing callback; positive call-graph gate test * test(db): entity metadata must match the migrated schema; fix the four drifts it found * feat(db): primary-relation entities proven (BudgetCategoryOrder, VacayUserSettings) * test(db): parity gate also holds declared pivot entities; per-field column types; missing-set ratchets both ways * test(db): numeric ids for the primary-relation spike * feat(db): re-runnable entity generator encoding the snake_case/DbTimestampType rules * fix(db): generator guard rails — validation cannot mask hidden()/index loss; typed JSON; loud failures * fix(db): generator keeps NOT NULL boolean defaults; fails if a JSON interface import survives * refactor(db): every entity in the snake_case shape; document-sync entities added; one repository per entity * fix(db): no NaN class-field initialisers; entity generator --check gate in CI; parity normaliser keeps literal case * chore(db): entity --check reports stray files; class-field census is a ratchet; migrate-encryption typechecked * feat(db): AppSettings and Users repositories; forFeature wiring pattern * refactor(permissions): permission flags through AppSettingsRepository * fix(server): boot-time settings read runs inside an explicit ORM request context * refactor(audit): audit log writes through AuditLogRepository * fix(db): PK reads refresh past the identity map; typed owned-lookup generics; e2e ORM module mirrors production config * docs(db): owned-lookup call-site convention; refresh side effect; e2e ORM module spread notes * fix(audit): audit rows are native inserts, not a unit-of-work flush; closed test ORMs * fix(server): non-HTTP entrypoints run inside an ORM request context; permission loads fail closed on context errors * refactor(categories, tags): repositories replace raw SQL * refactor(addons): addon catalog and photo providers through repositories * fix(db): generator captures inline UNIQUE constraints SqliteSchemaHelper.getIndexes() drops every unnamed sqlite_autoindex_ entry before entity-generator ever sees it, so a table-level UNIQUE(...) constraint with no separate named CREATE UNIQUE INDEX (settings' UNIQUE(user_id, key) among ~40 others) never reached the generated entity's uniques: metadata. Adds a general RULE9_addImplicitUniqueConstraints (skips a column set identical to the table's own primary key — AppSettings.key/Addons.id noise) fed by a PRAGMA index_list/index_info scan, and regenerates every entity. * chore(addons): well-known gate wraps only the addon read in a request context; drop unused test factories * fix(platform): well-known gate tolerates a synchronous addon read from the context helper * fix(db): PK lookups inside patch refresh past the identity map; corrected identity-map notes * refactor(settings): user and instance settings through repositories SettingsRepository (getForUser/upsertForUser/getOne/deleteForUser) replaces the per-user settings table's raw SQL; SettingsService drops DatabaseService entirely. instance-api-keys.ts's readInstanceApiKey/writeInstanceApiKey/ resolveApiKey take an explicit AppSettingsRepository/UsersRepository instead of a raw connection. An earlier version resolved those two repositories itself from the active MikroORM request context, so none of its six callers needed to change. That worked in production and e2e (always inside a real request) but broke service construction in plain unit tests across every calling domain (addons, auth x2, maps, transit, unsplash - 185 failures on a full run), since none of them wrap a request context. Each of those six services now injects its own AppSettingsRepository/UsersRepository and passes them through explicitly, matching how every other converted repository in this migration is consumed; their own SQL is untouched (outside Plan 3a). * test(server): harness constructors follow the Task 5 repository signatures; one test ORM per handle in the plugin host * fix(db): generator names relation properties in captured UNIQUE constraints * fix(settings): drop dead wiring and the speculative deleteForUser; strict row assertions * fix(server): plan 3a review follow-ups — fail closed without an ORM at every choke point; seam and D5 ratchet tests; dead method and DDL copies removed * fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned * feat(db): generated entities declare their repository type; test casts dropped * fix(server): plan 3a re-review 2 follow-ups — absent-ORM boot branch pinned; airports boot hook never rejects; BOOT-SWEEP-001 asserts the seven routes; duplicate comment removed * feat(server): pre-init photo route and WS handshake run in a request context; InviteTokensRepository * refactor(auth): users table through UsersRepository; JWT and MFA lookups repository-backed * refactor(tokens): MCP and API tokens through McpTokensRepository * refactor(auth): passkeys and registration invites through repositories * fix(db): row-out repository reads bypass the identity map — stale write-back over nativeUpdate at flush; insertUser via em.insert; settings round-trip pinned * fix(server): non-numeric route ids answer the legacy not-found, never NaN (tokens, categories, tags); mint path back to two statements; identity-map regression pinned * refactor(oauth): OAuth server tables through repositories OauthClientsRepository/OauthTokensRepository/OauthConsentsRepository replace OauthService's raw SQL (OA1-OA33). revokeChain's recursive CTE and the oauth_clients join (its FK actually targets client_id, not the row id the generated relation assumes) go through em.getKysely(); OA19's chain revoke is one nativeUpdate($in); OA12's consent grant is em.upsert on the (client, user) unique key. revokeAllForUser built now for Task 5. Every row-out read is disableIdentityMap: true; inserts go through em.insert. * fix(auth): non-numeric passkey and invite ids answer the legacy 404, never NaN; invite join type pinned * fix(db): generator emits the referenced column for non-PK foreign keys; OAuth client joins through the relation; FK parity un-skipped * refactor(db): TrekRepository base — every repository path validates the request context and reads bypass the identity map by default * refactor(auth): AuthService and account cleanup through repositories * refactor(oidc): OIDC identities and settings through repositories * fix(auth): invite validation keeps expires_at null on the wire; password transaction scopes and reset-token burn pinned * fix(server): plan 3b close-out — case folding on both sides for identifier lookups (non-ASCII login regression); coverage ratchet restored; repository scaffold and instanceof ratchet on TrekRepository; D-shape tests load-bearing * refactor(server): trip-access primitives async at every caller * refactor(trips): trip access through TripsRepository; demo seed in a request context; dialect helpers for the trips cluster * refactor(days): days and day notes through repositories * refactor(assignments): day assignments through repositories * refactor(server): trip-membership, place-shadow, place-enrichment, place-photos and query helpers through repositories * refactor(trip-members): membership, guests and the trip read model through repositories * refactor(places): places, tags, ratings and place images through repositories * fix(server): place-shadow source counts ordered in SQL; identity-map ratchets load-bearing; photo-cache race pinned * refactor(places): import paths through repositories * refactor(trips): trips through repositories * fix(server): trip ids parsed once at the gate for places and assignments; NUL-safe value quoting on the SQLite platform; places parity tests completed * refactor(trips): trip copy through repositories for the owned tables * fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and +/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding (rule 22 extension) — the program-wide fail-safe for any caller that still passes an unguarded Number(untrusted) into a converted statement. PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing that: verifyTripAccess parses once with toRowId and threads the parsed id through create/import/bulk/rating/export.gpx (deleting the seven toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard. DaysService gets the same treatment across list/create/getDay/reorder/ insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id could renumber a real trip's days while the raw-bind inverted-accommodation guard silently matched nothing. DatabaseService.getTripTitle is deleted; PlacesService injects TripsRepository directly and calls getTitle itself. Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses $castTo instead of an unknown bridge; the duplicated TagRow interface in Places.repository.ts is now imported from Tags.repository.ts; AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of restating its columns; PlaceShadowPicksRepository.totals drops its unreachable ??-fallback branch; Places.repository.ts's updatePlace types the NOT NULL name column as string; trip-members.service.ts's transferOwnership threads one parsed trip id through all three writes and corrects two stale "raw-bind seam" docstrings that were actually Number() seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts. Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed (lint back to 2416); HTTP and WS regression coverage for the non-finite and non-canonical id shapes across places/days routes, mutation-proved; TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of >= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's measured transaction-yield/dirty-read/nested-BEGIN behaviour. * test(server): plan 3c close-out — copy-source full-row parity, Kysely rollback proofs, identity-map relabels, trips e2e on the snapshot DB * fix(server): plan 3c close-out — days create/insert answer the guard 404 for non-canonical trip ids instead of 500; exact settlement and cross-request yield ratchets * feat(server): plan 3d groundwork — dialect helpers, visibility predicate, feed and cron context ratchets * refactor(roadtrip): roadtrip tables through repositories * refactor(reservations): reservations, endpoints and travelers through repositories * refactor(feeds): ICS feed tokens and anonymous feeds through repositories * refactor(accommodations): stays through repositories inside the existing transactions * test(server): plan 3d — MCP test helper constructors follow the accommodations and days services * refactor(trips): trip copy through repositories for the roadtrip, accommodation and reservation tables * refactor(calendar): calendar reads and reservation visibility through one predicate * feat(server): plan 3e groundwork — packing visibility predicate, memories access-gate audit * fix(server): plan 3d close-out — legacy text shape for stored accommodation ids restores the date-change restamp; reservation ids parsed once at the gate; parity tests for the reservation repositories * refactor(photos): trek photos, memories access and cache sweeps through repositories * refactor(files): trip files and file links through repositories, with transactions added * test(server): plan 3e — hand-built FilesService, FilesRpc and AllowedFileTypesService calls follow the repository constructors * refactor(memories): unified, immich and synology photo sync through repositories * refactor(collab): notes, polls, messages and links through repositories * refactor(budget): budget items, members, payers and settlements through repositories * refactor(todo): todo items through repositories * refactor(packing): packing items, bags and templates through repositories * feat(server): plan 3f groundwork — systemNotices DI shape, TRAVELER_OWNS predicate, ntfy/storage-health specs, composite-PK pins * refactor(school-holidays): country and region catalog through repositories * refactor(notifications): in-app notifications and channel preferences through repositories * refactor(notifications): reminder crons, mailer, ntfy and webhook transports through repositories * fix(server): plan 3e close-out — repository registrations restore the boot; packing assignee and bag updates parse and no-op as legacy; coverage and scoping ratchets for the budget, packing and collab repositories * refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories * refactor(system-notices): notice dismissals through a Nest provider and repository * refactor(atlas): visited countries, regions and bucket list through repositories * feat(server): plan 3g groundwork — GALLERY_CHRONOLOGICAL_ORDER Kysely ordering, unixEpochToIsoKysely helper, composite-PK DDL pins, JB5/R7 confirmations * refactor(journey): journey and trip access control, CRUD and the trip-sync engine through repositories * feat(server): plan 3h groundwork — date-offset dialect helpers, entity/repository fitness confirmations, pre-init and encryption pattern confirmations, survivor re-grep * fix(server): plan 3f close-out — ntfy operator-scope test on the live send path; vacay date-offset and shift-order parity; todo reminder window; coverage and parity ratchets for the atlas and vacay repositories * refactor(journey): journey stats, entries, photos, contributors and suggestions through repositories * refactor(journey): public share links, TREK Studio books and the journal facades through repositories * refactor(collections): access model, hydration, export/import and list CRUD through repositories * refactor(server): journey-table survivors in trips, photos, memories, auth-cleanup and plugin-contributions through repositories * refactor(integrations): dawarich connection, sync, tracks and review-flow through repositories * refactor(collections): saved places, copy-to-trip, labels and invitations through repositories * refactor(integrations,booking-import,airports,maps,route-usage): airtrail, booking import, airport backfill, maps cache and route-usage counters through repositories * refactor(doc-sync): connections, reconciler and controller-SQL relocation through repositories * test(integrations): add AirTrail saveSettings tx-asymmetry ratchet (Plan 3h Task 4 addendum) * fix(server): plan 3g close-out — transactional book first-save, skeleton reconcile and share-link create; unknown-key entry patches are no-ops again; parity and authorization pins for the journey repositories * feat(server): plan 3i groundwork — AuditLogRepository's reserved methods, the demo connection-swap spike, repository-fitness confirmations * feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness * refactor(storage): backend/category config and usage stats through AppSettingsRepository; fix the seed-file test race and the shared-replica migration-flip gap * refactor(share): public share links, the pre-init photo fallback, and collection-places survivors through repositories * feat(server): plan 3j — the withCamelCase utility, unwired, pending a contract decision * refactor(admin): user CRUD, stats, audit log, version check and addon config through repositories * test(admin): pin createUser's pre-existing non-transactional uniqueness-check race (AD2-4) * refactor(plugins): the trip-write role gate through a repository, in isolation * refactor(demo,backup): seed and reset through DemoRepository, live-connection backup statements through MaintenanceRepository, isDemoUserId through an injected DemoService * fix(server): plan 3h close-out — new-value-wins COALESCE restores stay/reservation propagation; doc-sync state filter truthiness; request-context and parity ratchets for share, doc-sync and the integrations * refactor(plugins): runtime lifecycle, config CRUD and the uninstall cascade through repositories * fix(server): plan 3i close-out — DemoModule imported where its service is consumed (e2e restored); admin addons list selects the provider relation; saveDemoBaseline awaited; backup tests on the maintenance repository * refactor(plugins): outbound OAuth tokens and per-user plugin settings through repositories * refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories * refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories * test(server): plan 4 — collapse mcp tool test harnesses onto the ORM snapshot * test(server): plan 4 — collapse nest service test harnesses onto the ORM snapshot * fix(server): plan 3j close-out — serialized audit-chain appends, atomic metadata and scheduler limits, rollback and race proofs for the plugin repositories * refactor(server): plan 4 — convert the program's orphaned raw-SQL callers * fix(server): plan 4 — un-skip PARITY-009 (FK deleteRule and index-name parity) * refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (trip-domain services) * refactor(server): plan 4 — inline canAccessTrip/isOwner off DatabaseService (plugins + MCP surfaces) * fix(server): plan 4 — RULE12 paren-count scanner ignores string literals The entity generator's RULE12_fixGarbledCheckExpressions CHECK-expression paren-balance scanner counted every `(`/`)` character regardless of whether it fell inside a single-quoted SQL string literal. A CHECK expression like `status IN ('confirmed)', 'tentative')` has a literal `)` inside its first string value; the scanner would hit depth 0 at that in-string character and truncate the expression, losing its own real closing paren. The scanner now tracks single-quote string state (handling the doubled `''` SQLite escape) and skips parens while inside one. Latent-bug fix — no shipped entity's CHECK expression has hit this shape yet (3d's ledger: "nothing affected today"). * refactor(server): plan 4 — unify the trip-access predicate's Kysely copies TripsRepository.findForViewer/listForUser/activeTrip each hand-wrote the identical trip-access boolean (t.user_id = ? OR m.user_id IS NOT NULL) through the Kysely expression builder. Task 7's security review (M1) evaluated a shared helper and set it aside for time, falling back to the TRIPREPO-041 cross-method parity test as a regression guard instead. Extracted the boolean into one generic helper, tripAccessExpr (_shared/trip-access.ts), following the reservation-visibility.ts publicReservationExpr precedent: fully generic over since the predicate has no correlated subquery, so it composes across TripSelectKyselyDB and ActiveTripKyselyDB without needing a narrower fixed-alias shape. The LEFT JOIN itself stays hand-written at each call site — tripSelectQuery's own docstring already explains why a shared join helper can't be typed across these differently-joined outer queries. feeds.service.ts and memories-access.service.ts no longer duplicate this predicate at all (Task 2's facade-inline sweep already converted both onto TripsRepository.findAccessible/getFeedTokenIfReachable/ listReachableActiveTrips, which read through the canonical MikroORM QueryBuilder form, accessibleTripsQuery) — the remaining duplication this task closes is the three Kysely copies inside this file. TRIPREPO-041 (64/64 in the file) stays green as the unification's own regression guard. * fix(server): plan 4 — trek_photo_cache_meta.cache_key NOT NULL The column was a bare TEXT PRIMARY KEY, which SQLite does not implicitly make NOT NULL (unlike an INTEGER PRIMARY KEY rowid alias). Every writer (TrekPhotoCacheMetaRepository.upsertMeta, the table's only INSERT path) has always bound a real string — 3e's R5 confirmed this. Appends a new migration rebuilding the table with cache_key NOT NULL (SQLite has no ALTER COLUMN, so this follows the same rebuild recipe Migration20200101020000 uses in the opposite direction) and updates the entity/generated metadata to match. Schema tightening, not a behaviour change. * refactor(server): plan 4 — retire the safely-convertible string-form where conditions (rule 23) Places.repository.ts's existsInTrip/findInTrip/findRaw/reclaimInputs/ listByTripAndIds and Trips.repository.ts's findPublicForShare all took a raw `.where('col = ?', [v])` string condition even though their id/trip_id parameters were already plain `number` (or, for findPublicForShare, narrowed to `number` here after confirming its one caller, share.service.ts#getSharedTripData, only ever passes a number sourced from a Kysely-typed share_tokens row, never an unconverted URL param) — converted all six to MikroORM's typed `.where({...})` filter object. The remaining raw string-form conditions in src/db/repositories/** are NOT converted here: they are the `number | string` raw-bind seam findAccessible/isOwner's own docstrings describe (trip_id/id threaded through unconverted from req.params), which structurally requires the deferred guard-level single toRowId parse to land first — narrowing any of those methods' signatures now would either break the '0x10'/'007' id parity those docstrings warn about, or duplicate the deferred item's own work ahead of it landing. * refactor(server): plan 4 — trek-photos.repository.ts finishes its rename 3e Task 6 renamed the CLASS (TrekPhotosRepository -> TrekPhotoRegistrationService, since the name collided with the generated ORM TrekPhotos.repository.ts) but left the FILE and its test file's names unchanged. Renames both to trek-photo-registration.service.ts / .test.ts. The old path is kept as a thin re-export stub (the src/websocket.ts shape) rather than repointed at every one of its ~20 importers in this pass: several of those files sit in two sibling tasks' exclusively-owned windows on this shared branch (Task 3's budget/packing/todo/accommodations/places ripple, reached through tests/helpers/plugin-host.ts and mcp-test-controllers.ts; Task 5c's held-back tests/unit/services/{trekPhotoMedia,conflictUpdate}.test.ts) and are off limits here. Flagged for a follow-up once those tasks land: delete the stub and repoint every remaining importer at ./trek-photo-registration.service. * fix(server): plan 4 — plugin discovery's delete+reinsert pairs are atomic install/discovery.ts#upsert refreshed a plugin's actions and settings-field descriptors as two independent delete-then-reinsert pairs (DI5-DI8) with no transaction spanning either pair, let alone both: a crash between a delete and its own reinsert left that plugin with an EMPTY actions or settings-field set until the next discovery run, instead of its previous still-valid rows. Wraps both pairs in one uow.transactional, so a discovery refresh for a plugin's descriptors is now all-or-nothing. Deliberate behaviour change, named for the user per the task brief. DiscoveryRepos grows a UnitOfWork; PluginRegistryService (the other production caller, registry.service.ts) picks it up the same @Optional() way PluginRuntimeService already does, with the same guard-throw. Updates the two hand-constructed test call sites that actually exercise discovery (discovery.test.ts, registry.test.ts) to pass a real UnitOfWork. * refactor(server): plan 4 — facade inline sweep part B (rosterUserIds/getPlaceWithTags) Inline DatabaseService.rosterUserIds onto TripMembersRepository.rosterUserIds directly in budget.service.ts, budget.mcp.ts and todo.service.ts, and DatabaseService.getPlaceWithTags onto PlacesRepository.findWithTagsAndRatings in accommodations.service.ts (placesRepo was already injected there). BudgetService/BudgetMcp/TodoService collapse their constructors, dropping the now-fully-unused DatabaseService param (budget.module.ts/todo.module.ts register TripMembers on forFeature). PackingService keeps DatabaseService as a vestigial param behind an optional TripMembersRepository with a fallback in tripRosterIds: an in-flight Task 5c file (tests/unit/services/conflictUpdate.test.ts) hand-constructs PackingService positionally without the new repository, so its arity cannot change this task; the fallback keeps that file's DatabaseService.rosterUserIds spy working until Task 4 drops the param with the class itself. * test(server): plan 4 — constructor ripple for the rosterUserIds/getPlaceWithTags inline Hand-built BudgetService/BudgetMcp/TodoService call sites drop the removed DatabaseService constructor arg (budgetRepoArgs() now appends a TripMembersRepository so most spread call sites need no other change); budget.service.test.ts's rosterHas() helper and svc() factory move from a spied DatabaseService to a stubbed TripMembersRepository. The AccommodationsService helper (tests/helpers/accommodations-service.ts) and its two direct callers drop the same arg. PackingService call sites are untouched (its new param is optional, so every existing hand-built instance still resolves rosterUserIds through the DatabaseService fallback). * fix(server): plan 4 — thread the discovery UnitOfWork through PluginRuntimeService too Complements the just-landed discovery atomicity fix (DiscoveryRepos.uow): PluginRuntimeService's own discoveryRepos getter still built the bundle without a uow field. Threads its already-injected, @Optional() uow through so its own onApplicationBootstrap/reload/dev-link discovery calls also get the transactional DI5-DI8 refresh, not just PluginRegistryService's. No throwing guard here, unlike PluginRegistryService's: several hand-built partial-DI-graph test instances of this class (boot-registry-order.test.ts, in a sibling task's held file window) construct it with uow omitted and exercise onApplicationBootstrap's discovery call directly. Discovery must never refuse outright just because a test built a partial graph, so it degrades to the pre-existing untransacted sequence when uow is absent (DiscoveryRepos.uow's own docstring covers this), the same "boot must never block app init" defensiveness this file already applies elsewhere. * fix(server): plan 4 — guard-level single parse: verify + correct the overclaiming verifyTripAccess docstring (L-2) TripAccessGuard/TripOwnerGuard already parse :tripId exactly once (Number(), guarded by Number.isFinite) and hand the resolved TripAccess row to the handler via request[TRIP_REQUEST_KEY], TripOwnerGuard reusing TripAccessGuard's own lookup when both are on the same route rather than re-querying — verified by reading both guards directly, no change needed there. PlacesService.verifyTripAccess's own docstring separately claimed its returned `tid` gets "returned... for every downstream call to reuse (never re-parsing)" (3c's Task 9 review, L-2, carried to Plan 4). Reading every call site (not assumed) shows this is false: `.tid` has exactly one reader (PlacesController.requireTrip itself), which discards it — none of requireTrip's 13 callers read `.tid` off the trip it returns; each passes the original raw tripId string on to the write/read method it calls next, which toRowId-parses it again itself. That second parse cannot be eliminated by this gate alone: the same service methods are called directly by places.mcp.ts too, an entry point with no requireTrip gate at all (its own numeric tripId is re-stringified, String(tripId), to call them), so each method owns its own id validation regardless of entry point. Threading `tid` all the way through would mean giving every one of ~15 methods a second, number-only call shape for the REST path alone to use — a real refactor, out of this docstring fix's own scope. Corrected the docstring to say so plainly instead of repeating the false claim. * refactor(server): plan 4 — relocate JS7/10/13/14 off the journey-share fallback stub JourneyShareTokensRepository's own JS7/JS10/JS13/JS14 Kysely reads were a 3g Task 3 fallback (Task 2's JourneyPhotosRepository/JourneyEntriesRepository/ JourneyEntryPhotosRepository were still mid-flight). Now that those repositories are stable, relocate: JS7/JS10 (photo/asset validation joins) onto JourneyPhotosRepository (already injected into JourneyShareService as photosRepo, no constructor change); JS13 (public entry list) onto JourneyEntriesRepository.listPublicEntries; JS14 (public per-entry photo read) onto JourneyEntryPhotosRepository.listForPublicJourney, deduped against that repository's own JP_COLUMNS select list instead of a hand-duplicated copy. JourneyShareService gains two new @InjectRepository params (both already registered in journey-domain.module.ts's forFeature), rippled through mcp-test-controllers.ts and journey-share.service.test.ts (P04/P05 parity tests now pin the new homes). * refactor(server): plan 4 — document unreachable ?? default arms after single-row aggregates 3f Task 7's fix wave flagged a cluster of `row?.x ?? default` arms after unqualified, ungrouped aggregate reads (MAX/COUNT/COALESCE(SUM,0)) as "same class as OauthClients:147" but left them unexamined per-site. Decided per site: document, don't delete — TypeScript still types executeTakeFirst()'s result as `| undefined` even though SQL guarantees exactly one row for these unqualified aggregates, so the optional chain can't be removed without a non-null assertion (worse, not safer). Six sites: JourneyEntries.repository.ts (maxSortOrderForDate JG42, countStatsPlaces JG67), JourneyEntryPhotos.repository.ts (maxSortOrderForEntry JG91), JourneyPhotos.repository.ts (maxSortOrder JG88/JG99), VacayEntries.repository.ts (sumFraction VC1, sumCompFraction VC2). No behavior change — comments only. * refactor(server): plan 4 — unify the two user-data-erasure enqueue paths UserCleanupService.erasePluginUserData (the account-deletion transaction's own UC2/UC3 enqueue) and PluginRuntimeService.enqueueUserErasure (the emitUserDeleted sink's post-commit enqueue) independently walked the identical SELECT id, permissions FROM plugins + JSON.parse + hook:user-data filter + INSERT OR IGNORE — and both genuinely run for every account deletion, since every emitUserDeleted call site (auth.service.ts, admin.service.ts, trip-members.service.ts's guest removal) fires immediately after deleteUserCompletely. Extracted the shared filter into enqueueHookUserDataErasures (nest/plugins/user-erasure-enqueue.ts) and pointed both call sites at it. UC2/UC3 also come off DatabaseService onto PluginsRepository/ PluginUserErasureQueueRepository in the same change (they were the last raw statements standing in the way of sharing one implementation) — this narrows the Plan 3b Task 5 "stays raw, owned by Plan 3j" ruling to UC1 only (plugin_user_config/plugin_oauth_tokens/plugin_oauth_state), which still stays raw, unaffected. UserCleanupService's orphan-data-directory scan (a plugin uninstalled with data retained) has no equivalent on the enqueueUserErasure side and stays there, now via the same repository. Both enqueue calls still run per deletion (INSERT OR IGNORE keeps this idempotent) — this unifies the IMPLEMENTATION, not the call count; not touching the emitUserDeleted event-sink wiring itself was a deliberate, smaller-blast-radius choice for this task. * refactor(server): plan 4 — narrow TripMembersRepository.remove/TripsRepository.setOwner to number Both accepted trip_id: number | string per 3c's carry note (their "stays loose for other callers" reasoning), pending the guard-level single parse landing first. Re-checked every caller directly: setOwner's one production caller (trip-members.service.ts#transferOwnership) already passes trip.id, a real number resolved earlier in the same transaction; remove's callers are trips.rpc.ts (num(), already a validated number), trips.mcp.ts (Zod- typed tool input, already a number) and TripMembersController.removeMember (the one genuine raw-string caller, :id off the route with no class-level TripAccessGuard on this controller by design). TripMembersController.removeMember now parses :id once (toRowId, not Number() — rule 15's NaN-into-SQL trap) and threads the parsed number to both its own access check and removeMember, instead of passing the raw string through twice. TripMembersService.removeMember and both repository methods narrow to number accordingly; setOwner's WHERE also moves off its raw-bind escape hatch onto nativeUpdate now that trip_id is typed, and remove's trip_id half moves onto a typed .where({ trip: trip_id }) filter (user_id keeps its own raw bind — a different, still-live NaN-safety seam, unrelated to this narrowing). TRIPREPO-018 and TMEMREPO-023, which pinned the retired string-bind seam on setOwner/remove, no longer typecheck against the narrowed signatures and are removed with an explanatory comment in their place. * refactor(server): plan 4 — U6 gate-level id parsing for todo item ids Rule 21's carry from 3e Task 8's review: TodoController.update/.remove passed the raw :id route string all the way to TodoItemsRepository, relying on SQLite's column-affinity CAST to match it instead of a typed filter. Parse :id ONCE at the controller (toRowId, matching Task 8a's TripAccessGuard/removeMember shape), 404 on a non-canonical id, and thread the number down through TodoService.updateItem/deleteItem into TodoItemsRepository.findInTrip/existsInTrip/update/deleteById, all narrowed from number | string to number. todo.rpc.ts's num()-derived todoId no longer needs its String() wrapper. tripId stays string | number — a separate, already-accepted carry (23c292f2f). Two new e2e tests pin the non-numeric-id 404 at the gate. * test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4) 3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests. 17 cases pinning every read against the legacy statement run raw on the same connection — findRawByTrip/findTokenByTrip with every nullable column both null and set, findValidByToken/findTripAndShareMapByToken/ findTripIdByToken's shared token+expiry predicate (valid, future-expiry, past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/ deleteByCreator's write correctness (deleteByCreator's cross-trip erasure kept distinct from deleteByTrip's single-trip scope, per the repository's own docstring). The listPublicForShare family (the other half of 3h L4) is a separate, larger carry — not in this file. * test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry) Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts reads on ReservationsRepository flagged by the 3d Task 7 review as having no repository-level toEqual() coverage (listEndpointsForShare, listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare, listScheduledForPublicApi, listUnscheduledForPublicApi, listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new cases against the legacy statement run raw on the same connection. * test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry) Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService .save's belongs-check-then-upsert, and FeedsService.generateTripToken's read-then-write. Both tests pin today's actual outcome (an atomic ON CONFLICT upsert survives cleanly; the token mint has a last-write-wins loser) with the same concurrent-request-shape Promise.all/allSettled pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix. * test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry) Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1), DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's "findTrackInTrip" — the actual method name). Completes the 11-file carry together with 434113937's 8 share/public-api reads. * test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks 235f03665 landed two versions of the same DaysRepository.listPlanDays and PlacesRepository.isTrackInTrip parity coverage side by side (a merge artifact from concurrent work on the same carry item). Keeps one block per method, drops the duplicate describe/it blocks and their colliding test ids. * test(server): plan 4 — budget calc suite onto real repositories (3e carry) * test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3) Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings), document_connections.secrets (DocSyncConfigService's whole-secret-map column), and trip_document_links.webhook_secret (createLink's per-binding mint). Each test saves through the real service, reads the raw column via better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never contains the plaintext, and asserts the service's own decrypt path rounds it back to the original value. * test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry) Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key has no at-rest pin", "document_connections.secrets ... has no enc:v1: check", "trip_document_links.webhook_secret - no at-rest pin"). Three targets, each proven by reading the raw column and asserting the stored value is enc:v1:-prefixed ciphertext that round-trips, never the plaintext: UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the service layer, and DocSyncConfigService's connection secrets map plus createLink's webhook secret. * test(server): plan 4 — drop duplicate AirTrail encryption repository test Task 8b-2 item 4 follow-up: 6965869dd already covers UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest ciphertext at the service layer (airtrail.service.test.ts's AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins (DOCSYNC-ENC-001/002); this repository-level file duplicated the same AirTrail assertion a second time. * test(server): plan 4 — atlas read-model parity remainder (3f L6 carry) Task 8b-2 item 3 (atlas half): full-key toEqual() coverage for the atlas read models the 3f Task 7 review's L6 finding listed as missing (minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places. listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/ listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/ listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/ listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser), AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/ countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34 (BucketList.listForUser/findById/findForUser), AT41 (Places. listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids only by the existing consumer test — full-row parity for those three is NOT done here (carried forward, same as the review's own open item). * test(server): plan 4 — vacay read-model parity remainder (3f L6 carry) Task 8b-2 item 3 (vacay half): full-key toEqual() coverage for the vacay read models the 3f Task 7 review's L6 finding listed as missing (minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the getPlanData/getStats composite's own plan read), VacayYears.listForPlan (listYears), VacayCompanyHolidays.listForPlan (the company-holidays list), VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads), VacayUserSettings.findForUser (user year settings), VacayUserYears. findForYear (getStats rows, including the NULL vacation_days case the review named explicitly), and VacayPlanMembers.listAvailableForFusion/ listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven new standalone files — only VacayShares had a repository-level test before this. getSharedCalendars itself is a service-level composite over these same reads plus VacayCompanyHolidays.listDatesForRange (already exercised elsewhere); its own dedicated test is NOT added here. * test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4) Task 8b-4a: no repository-level toEqual() coverage existed for the share.service.ts SH13 read. Seeds Common items with every nullable column both null and set, plus Personal and Shared-with-people items on both sides of the is_private/owner/recipient predicate, and proves only the Common tier surfaces. * test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4) Task 8b-4a: no repository-level toEqual() coverage existed for the share.service.ts SH14 read. Seeds two items — every nullable column set and every nullable column null — plus a foreign-trip item, and proves the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has none). * test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4) Task 8b-4a: no repository-level toEqual() coverage existed for the share.service.ts SH16 read. Seeds a root message, a reply (reply_to set), a deleted message (must be excluded) and a foreign-trip message, and proves the narrower username/avatar-only join shape (no reply_text/ reply_username, unlike joinedQuery). * test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4) Task 8b-4a: no repository-level toEqual() coverage existed for the share.service.ts SH12 read. Seeds a categorised place with every nullable column set and an uncategorised place with every nullable column null, plus a foreign-trip place, and proves the named 20-column allow-list never leaks an owner-only column (reservation_status, google_place_id, …). * test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4) Task 8b-4a: no repository-level toEqual() coverage existed for the SH9 read. Seeds assignments across multiple days (one excluded day_id never leaks in), every nullable place/assignment column both null and set, and proves the six owner-only columns SharePublicAssignmentRow omits (google_place_id, google_ftid, osm_id, amap_poi_id, stop_type, fill_percent) never appear on the row at all. * test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests * test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up) * fix(server): add the missing numbered migration for the booked-night reseat step (#242) reseatBookedNights (legacy step 242) was never invoked from any shipped MikroORM migration -- only from the retired db/migrations.ts array -- so an install upgrading through the numbered chain never got the one-time reorder. Adds Migration20200101040300, which calls the frozen function through the native better-sqlite3 handle so it runs inside the migration's own transaction. orm-driver.ts's BoundSqliteConnection now also captures that handle onto the field SqliteConnection.getNativeClient() reads, which its createKyselyDialect() override previously left unset -- needed for this migration (and any future one) to reach it in production, not just under the bare test driver. RESEAT-009 moves off the legacy createTables+runMigrations builder onto the real Migrator via tests/helpers/migration-step.ts, plus a new fresh-install guard (RESEAT-011). * test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder day-notes.service.test.ts, public-api-scopes.test.ts, registration-invites.service.test.ts, trip-invite.service.test.ts onto createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent), dropping their own hand-rolled createTables()+runMigrations() harness. trip-invite.service.test.ts and registration-invites.service.test.ts also drop their now-redundant DatabaseService wrapper (its .connection was always just the same test db handle). expect( assertions unchanged. user-cleanup.service.test.ts held back: its constructor call was updated to match UserCleanupService's new EntityManager-based shape, but deleteUserCompletely() now deadlocks against a real UnitOfWork (reproduced in isolation, independent of this test file -- see the report), which looks like a genuine bug in erasePluginUserData's new MaintenanceRepository(em) call versus UnitOfWork.transactional(), not a test-harness problem. * test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb() Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for their own archiver/filesystem assertions, which broke createSnapshotTestDb()'s read of the schema snapshot through schema-snapshot.ts's own fs import. auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now built via a small helper that points fsMock's three calls at the real filesystem (fetched through vi.importActual, bypassing the mock) just long enough to build the db, before the test installs its own existsSync stub -- never touching schema-snapshot.ts itself. demo-reset-path.test.ts: fs.existsSync is selectively spied to answer only for the baseline path, so the db is now built before that spy is installed instead of after. * test(server): plan 4 — retire the legacy-runner-only migration hygiene checks migration-hygiene.test.ts deleted outright: every block in it (the destructive-op allowlist, the empty-catch guard, the full-chain smoke) scans or runs the legacy db/migrations.ts array being deleted this plan, never the numbered src/db/migrations/*.ts chain, so there is nothing in it to carry forward. leg-mode-incoming.test.ts: deleted its first describe block (the legacy-runner column-add + replay-safety smoke, also against db/migrations.ts); the read-path parity block, already on the modern createSnapshotTestDb() harness, is untouched. * chore(server): delete the legacy schema/migration/seed builders MikroORM's migrations are the only schema source now, including for tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are gone, along with tests/unit/db/schema-parity.test.ts (the parity gate that guarded them, already red on 8a's cache_key NOT NULL) and createTestDb() + its now-dead imports out of tests/helpers/test-db.ts (its last two callers moved onto createSnapshotTestDb() this plan). Their three entries drop from eslint.config.mjs's better-sqlite3 allow-list -- the files they exempted no longer exist. Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts, document-provider-seed.ts) and every numbered migration in src/db/migrations/ are untouched. Precondition grep (server/tests + server/src, from the task brief) is now empty: grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts" * docs: plan 4 — the repository layer is the only data access path DatabaseService/DatabaseModule are gone; queries go through repositories (server/src/db/repositories/) and UnitOfWork (server/src/nest/database/ unit-of-work.ts) for transactions. MikroORM's migrations are the only schema source, including for tests — the hand-kept schema.ts/migrations.ts/ seeds.ts test builders and their parity test are deleted. Documents the db/database.ts (connection only) / db/orm-driver.ts (bound driver) / UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints, the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/ check:entities) as the schema-parity guard. Corrects two stale nest/README.md mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1 trip-access bullet) and marks the legacy src/services/ migration recipe as a historical changelog rather than current guidance. * refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters Removes the now-dead DatabaseService injection (or private get db() getter) from addons.service.ts, trips.service.ts, trip-invite.mcp.ts, place-photo-cache.service.ts, trek-photo-registration.service.ts, categories.mcp.ts, journey-domain.service.ts, plugins.service.ts, plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts and packing.service.ts — every one of them was fully repository-backed already (Task 2/3's facade inline sweep), just still carrying the param. PackingService's TripMembersRepository is required now (the this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's uow is no longer @Optional() — both moved ahead of the optional registry?/hostFactory? params, since TypeScript refuses a required parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow is required to match, and its upsert() always runs the descriptor refresh inside one transaction instead of falling back to an un-transacted sequence. user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's "stays raw" plugin-table trio) moves off DatabaseService onto a new MaintenanceRepository.deletePluginUserData method — the rule-4-permitted connection.execute() home, built from a directly-injected EntityManager. Threads the active UnitOfWork.transactional's transaction context through execute()'s ctx param: without it, a raw connection.execute() call issued from inside deleteUserCompletely's open transaction contends for the same single-connection checkout the transaction already holds and deadlocks (never released, since the transaction is itself awaiting the call). tests/unit/db/transaction-yield.test.ts's probe B already pinned this exact hazard class for a raw better-sqlite3 statement; this is the same mechanism reached through the ORM's connection pool instead. nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim) is deleted, with every importer repointed to trek-photo-registration.service directly. The ~7 files that only imported the TripAccess/PlaceWithTags type re-export from database.service.ts now import them from Trips.repository.ts/Places.repository.ts directly, the actual single source. * refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts Grep gate confirmed zero src/ importers of DatabaseService left outside these three files (Task 1/2/3's earlier conversions + this task's own vestigial-param sweep). Deletes database.service.ts, database.module.ts and database.tokens.ts, drops DatabaseModule from app.module.ts's imports and the five other modules that still listed it (accommodations/calendar/doc-sync/feeds/trip-members) — none of them had a live DatabaseService injection left, so the import was already dead. OrmModule is the module that survives DatabaseModule's deletion, per the Phase 0 ledger's own ruling; untouched. db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test files' vi.mock('…/db/database', …) factories return a { db: , … } shape, and their own `import { db as testDb } from '…/db/database'` is typed against this file's real exports regardless of the runtime mock — vi.mock swaps the module at runtime, but tsc resolves the import statically. Deleting the export would fail typecheck:tests across the whole suite for a test-harness idiom this task's file set doesn't own (Track B's territory). src/ has zero remaining importers, so the ESLint no-restricted-imports gate this task also shrinks still holds this file to its permanent allow-list entry either way. * test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule Mechanical follow-through of the previous two commits across ~110 test files: - The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their partial Test.createTestingModule graphs; the import and the array entry are dropped (nothing in any composed domain module injected DatabaseService any more, so the entry was already dead weight). - The shared test helpers (mcp-test-controllers.ts, plugin-host.ts, accommodations-service.ts, notifications.ts, test-addons.ts, test-uow.ts) each held a DatabaseService/DatabaseService-shaped param used only for its raw better-sqlite3 handle; every one now takes that handle directly, and every constructor call site the helpers make (PackingService, PlacesService, JourneyDomainService, PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService, UserCleanupService, AddonsService, PluginsService, PluginRuntimeService) drops the removed param and, where PluginRuntimeService's uow is involved, reorders it ahead of the optional registry?/hostFactory? params to match the real constructor. - ~60 further unit/integration test files hand-constructed one or more of those same services directly; same fix, file by file. Four of them (memories-access.service.test.ts, places.directions.test.ts, places.service.test.ts, unified-memories.service.test.ts) also drop a vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner' | 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those methods delegated through DatabaseService — dead now that the services read the owning repository directly. - database-service.test.ts and database-service.delegation.test.ts tested DatabaseService directly and are deleted with it. - tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw DatabaseService statement's dirty-read behaviour inside an open transaction (rule 24), now issues the same raw better-sqlite3 statement directly against the shared test handle — the exact shape DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(), bypassing MikroORM/Kysely entirely) — so the hazard it documents is unchanged. Verified still green. No expect(...) assertion changed in any file; only construction/import plumbing. typecheck, typecheck:tests and the full touched-file run (101 files / 2668 tests) are green, plus the compiled boot (bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real e2e boot file (trips.e2e.test.ts). tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this commit — see the next one. * test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix This file arrived already modified and unstaged in the shared tree — Task 6 (the parallel legacy-schema-deletion task) had swapped its DB harness onto createSnapshotTestDb() and updated the UserCleanupService construction to this task's new EntityManager-first shape while landing its own commits. Finished here: USER-CLEANUP-005 ("survives a slim schema without the plugin tables") passed a fake `{} as unknown as EntityManager`, which doesn't reach the real UC1 path any more (the previous raw DatabaseService.run swallowed everything in a try/catch regardless); it now builds a real EntityManager bound to the slim handle via sharedTestOrm(slim), so the per-statement query against the missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state tables is what's actually being proven caught, not a construction-time throw. USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them exercising UC1 inside the real uow.transactional wrap) are the regression test for the previous commit's deadlock fix in user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every one of these hung and timed out. Green now, verified directly. * chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list Per ruling 6: database.service.ts's entry comes off (the file is deleted). The five plugin entries (plugin-route-normalize.ts, plugin-host-state.ts, install/discovery.ts, settings-defaults.ts, signature-status.ts) come off too — re-verified none of the five import better-sqlite3 or DatabaseService any more (3j's own conversion). The src/demo/** blanket entry was already gone (3i's own task landed it). reseat-booked-nights.ts and document-provider-seed.ts stay, with the `// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7), invoked from inside numbered, already-shipped MikroORM migrations, never Plan 4's or any future plan's to touch. Permanent allow-list after this commit: src/db/database.ts, src/db/orm-driver.ts, src/db/durability.ts (the driver homes), src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts (3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts entries were already removed by Task 6 when it deleted those files. 0 new lint errors; warning count unchanged at 2123 (well under the 2247 lean-gate ceiling) — every file the shrink touches was already fully converted. * refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every :id/:settlementId/:userId route param in BudgetController is parsed ONCE at the gate with toRowId, matching the legacy affinity-seam "not found" outcome for a malformed id on each route (404 'Budget item not found'/'Settlement not found', except toggleMemberPaid — the one legacy handler that never 404'd an unknown item/member either, so a malformed id there skips the write and keeps the same 200 { member } + Number(id)/Number(userId) broadcast shape it always had). The parsed numbers thread down through BudgetService's update/updateMembers/ setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/ applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById, all narrowed from number | string to number; trip_id stays loose, a separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no longer needs its String() wrapper on the update/delete calls; budget.mcp.ts was already Zod-typed to number throughout. Two new e2e tests pin the non-numeric-id 404 at the gate; controller/service unit tests cover the narrowed call args and the toggleMemberPaid malformed-id parity case. * refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every :id/:bagId/:templateId/:userId route param in PackingController is parsed ONCE at the gate with toRowId, matching the legacy affinity-seam "not found" outcome for a malformed id on the routes with a pre-ORM Express precedent (update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers -> 'Bag not found', applyTemplate -> 'Template not found or empty'). #858's sharing/contributors routes (setSharing/clone/addContributor/ removeContributor) are native Nest code with no pre-ORM precedent (landed in 7eabf6066, after the migration) — a malformed id there just 404s the same way an unknown one already does. removeContributor's :userId used to reach contributorsRepo.deleteOne through a bare Number.parseInt(), the exact NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too. The parsed numbers thread down through PackingService's updateItem/ setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/ getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed from number | string to number; tripId stays loose, a separate, already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/ update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/ update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer need their String() wrappers on the update/delete/updateBag/deleteBag/ setBagMembers calls; packing.mcp.ts was already Zod-typed to number throughout. Two new e2e tests pin the non-numeric-id 404 at the gate; controller/rpc unit tests cover the narrowed call args and the removeContributor malformed-userId parity case. AdminPackingTemplatesController (/api/admin/packing-templates) is out of scope — a separate admin-gated surface with its own string-typed template CRUD, not part of this trip-scoped controller's :id chain. * test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3) * fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1) An install the retired positional runner migrated has a schema_version row and no mikro_orm_migrations rows, so its first boot on this branch replayed all 244 migrations over an already-migrated schema: eight refuse the boot, and the replay-safe ones overwrite user data (step 26 rewrites every day_assignments.assignment_time). runSchemaBootstrap now records the baseline plus every migration whose "Legacy migration step N" docstring is <= schema_version as executed, in one transaction through the migrator's own storage, before the ordinary run applies the rest. The step map is derived from the discovered migrations' source, never hand-kept, and anything unmappable (a newer or malformed schema_version, a duplicated, missing or out-of-order step) refuses the boot. * test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline SQL dumps of databases the retired runner itself built: schema_version 205 (main at fa406b708), 242 (417d0445b, the runner's last commit) and 82 (v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead and run for the first time). Each gets canary rows: user-set assignment times and a booked night. buildApp() on each asserts the baseline and the applied rest in order, the assignment times untouched, the booked-night reseat run exactly once on 205 and 82 and not at all on 242, schema_version kept, no half-built *_new table, a schema identical to a fresh install's, and an idempotent re-run. * refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1) demo-reset.ts was the one src/ consumer of the db Proxy left, reached through a runtime require that a grep on `from` misses. It now imports closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts does, so the Proxy genuinely has no src/ importer (the claim in database.ts and server/CLAUDE.md now holds). The path test mocks the module with vi.mock instead of teaching Node's require loader to answer for a .ts file. * test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3) ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so each id names one test again. No test body changes. * refactor(server): derive the two restated public row types instead of copying their columns (m4) JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen columns with only the parent key changed; it is now that row with journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely table shape restated PlaceRow's columns; both now Pick them off PlaceRow. Types only. The review's CPD method over Plan 4's new src lines drops from about 3.4% to 0.64%. * test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5) Neither read had any test reference. Each is now pinned with toEqual(), the listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus a missing id (TRIPREPO-067/068), and VC90 over one world exercising every exclusion (caller, guest, existing share, plan owner, accepted member) and a missing plan. * fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1) The legacy runner ran the running release's createTables() on every boot before its pending steps, and about 30 tables exist only there. Marking the baseline migration as executed left installs older than those tables (below legacy step 68) failing with "no such table: photo_provider_fields" and, since the rows were committed before the run, stuck half-baselined for every later boot. Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs first, as the legacy boot did. The rows are logged inside the same transaction as migrator.up({ transaction }), so a failed first boot records nothing and the next boot baselines again. A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied, canaries kept. Its two schema differences from a fresh install (users.immich_access_token, reservations.accommodation_id INTEGER) come from the legacy chain itself and are pinned as carried. * fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without regenerating the lockfiles, so npm ci failed on every CI job. The root overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11, and the tests that select lucide icons by their old class names use the current ones. Two tests cover the Immich prefs reads and the boot restore branch that the coverage thresholds were missing. * ci: give the server lint step an 8 GB heap for the type-aware rules * test(server): keep the no-promise-as-value rule tester out of single-run mode on CI With CI=true typescript-eslint infers single-run mode, builds the program from the fixture file on disk and ignores the code each case passes in, so all 24 invalid cases reported nothing on the runner. Both testers now set disallowAutomaticSingleRunInference. * refactor(server): name the new migrations after the first seven words of their purpose, like the rest * fix(server): keep tsx a production dependency so image key rotation can load it * test(server): re-pin the repositories branch floor at the measured 89.83% * test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom * test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL * test(client): keep console output for failing tests only and answer the health probe * test(client): drop the health handler, it races the admin page's first load * chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures --------- Co-authored-by: Maurice --- .github/workflows/test.yml | 17 + CLAUDE.md | 4 +- client/package.json | 74 +- .../BackgroundTasksWidget.extra.test.tsx | 8 +- .../Packing/PackingListPanel.test.tsx | 8 +- .../PackingListPanelCategoryGroup.test.tsx | 4 +- .../Planner/DayPlanSidebar.test.tsx | 18 +- client/src/utils/iconMarkup.parity.test.ts | 3 + .../tests/environment/jsdom-native-abort.ts | 4 +- .../unit/mobile/trip/MRoadtripRows.test.tsx | 6 +- client/vitest.config.ts | 8 +- package-lock.json | 3819 ++++++++---- package.json | 4 +- plugin-sdk/package-lock.json | 38 +- plugin-sdk/package.json | 12 +- plugin-sdk/src/cli/ui.ts | 4 +- server/CLAUDE.md | 9 +- server/eslint-rules/fixtures/file.ts | 1 + server/eslint-rules/fixtures/strict.ts | 1 + server/eslint-rules/fixtures/tsconfig.json | 16 + .../fixtures/tsconfig.strict.json | 15 + server/eslint-rules/no-promise-as-value.mjs | 344 ++ server/eslint.config.mjs | 169 + server/package.json | 98 +- server/scripts/__fixtures__/sync-db-sample.ts | 23 + .../scripts/__fixtures__/unawaited-sample.ts | 60 + server/scripts/build.mjs | 33 +- server/scripts/db-call-graph.mjs | 1021 +++ server/scripts/generate-entities.ts | 2145 +++++++ server/src/app-config/nest-log-levels.ts | 25 + server/src/bootstrap.ts | 76 +- server/src/db/connection.ts | 5 +- server/src/db/database.ts | 219 +- server/src/db/db-path.ts | 34 + server/src/db/dialect/sql-functions.ts | 981 +++ server/src/db/document-provider-seed.ts | 290 +- server/src/db/entities/Addons.entity.ts | 33 + server/src/db/entities/AppSettings.entity.ts | 18 + .../entities/AssignmentParticipants.entity.ts | 26 + server/src/db/entities/AuditLog.entity.ts | 31 + server/src/db/entities/BucketList.entity.ts | 46 + .../db/entities/BudgetCategoryOrder.entity.ts | 24 + .../db/entities/BudgetItemMembers.entity.ts | 30 + .../db/entities/BudgetItemPayers.entity.ts | 28 + server/src/db/entities/BudgetItems.entity.ts | 69 + .../db/entities/BudgetSettlements.entity.ts | 46 + server/src/db/entities/Categories.entity.ts | 35 + server/src/db/entities/CollabLinks.entity.ts | 36 + .../entities/CollabMessageReactions.entity.ts | 31 + .../src/db/entities/CollabMessages.entity.ts | 44 + server/src/db/entities/CollabNotes.entity.ts | 45 + .../src/db/entities/CollabPollVotes.entity.ts | 31 + server/src/db/entities/CollabPolls.entity.ts | 41 + .../db/entities/CollectionLabels.entity.ts | 32 + .../db/entities/CollectionMembers.entity.ts | 33 + .../entities/CollectionPlaceRatings.entity.ts | 31 + .../db/entities/CollectionPlaces.entity.ts | 86 + server/src/db/entities/Collections.entity.ts | 48 + .../db/entities/DawarichConnections.entity.ts | 40 + .../DawarichVisitSuggestions.entity.ts | 86 + .../db/entities/DayAccommodations.entity.ts | 47 + .../src/db/entities/DayAssignments.entity.ts | 66 + server/src/db/entities/DayNotes.entity.ts | 38 + server/src/db/entities/Days.entity.ts | 59 + .../db/entities/DocumentConnections.entity.ts | 55 + .../entities/DocumentProviderFields.entity.ts | 37 + .../db/entities/DocumentProviders.entity.ts | 31 + .../db/entities/DocumentSyncItems.entity.ts | 94 + server/src/db/entities/FileLinks.entity.ts | 52 + .../src/db/entities/GoogleApiUsage.entity.ts | 18 + .../entities/GooglePlacePhotoMeta.entity.ts | 22 + .../src/db/entities/HiddenCountries.entity.ts | 26 + .../src/db/entities/HiddenRegions.entity.ts | 28 + .../src/db/entities/IdempotencyKeys.entity.ts | 32 + server/src/db/entities/InviteTokens.entity.ts | 37 + server/src/db/entities/JourneyBooks.entity.ts | 40 + .../db/entities/JourneyContributors.entity.ts | 31 + .../src/db/entities/JourneyEntries.entity.ts | 94 + .../db/entities/JourneyEntryPhotos.entity.ts | 29 + .../src/db/entities/JourneyPhotos.entity.ts | 43 + .../db/entities/JourneyShareTokens.entity.ts | 39 + server/src/db/entities/JourneyTrips.entity.ts | 27 + server/src/db/entities/Journeys.entity.ts | 64 + server/src/db/entities/McpTokens.entity.ts | 37 + server/src/db/entities/Migrations.entity.ts | 19 + .../NotificationChannelPreferences.entity.ts | 26 + .../src/db/entities/Notifications.entity.ts | 68 + server/src/db/entities/OauthClients.entity.ts | 46 + .../src/db/entities/OauthConsents.entity.ts | 31 + server/src/db/entities/OauthTokens.entity.ts | 52 + server/src/db/entities/PackingBags.entity.ts | 41 + .../PackingCategoryAssignees.entity.ts | 28 + .../PackingItemContributors.entity.ts | 30 + server/src/db/entities/PackingItems.entity.ts | 58 + .../PackingTemplateCategories.entity.ts | 27 + .../entities/PackingTemplateItems.entity.ts | 30 + .../db/entities/PackingTemplates.entity.ts | 28 + .../db/entities/PasswordResetTokens.entity.ts | 32 + .../db/entities/PhotoProviderFields.entity.ts | 41 + .../src/db/entities/PhotoProviders.entity.ts | 28 + server/src/db/entities/Photos.entity.ts | 45 + .../db/entities/PlaceDetailsCache.entity.ts | 24 + server/src/db/entities/PlaceRatings.entity.ts | 31 + server/src/db/entities/PlaceRegions.entity.ts | 25 + .../db/entities/PlaceShadowPicks.entity.ts | 39 + server/src/db/entities/Places.entity.ts | 127 + .../src/db/entities/PluginActions.entity.ts | 28 + .../entities/PluginCapabilityAudit.entity.ts | 32 + .../db/entities/PluginEgressHosts.entity.ts | 22 + .../entities/PluginEntityMetadata.entity.ts | 34 + .../src/db/entities/PluginErrorLog.entity.ts | 29 + .../entities/PluginMetaMigrations.entity.ts | 21 + .../db/entities/PluginOauthState.entity.ts | 24 + .../db/entities/PluginOauthTokens.entity.ts | 28 + .../entities/PluginScheduledTasks.entity.ts | 28 + .../entities/PluginSettingsFields.entity.ts | 42 + .../db/entities/PluginUserConfig.entity.ts | 22 + .../entities/PluginUserErasureQueue.entity.ts | 22 + server/src/db/entities/Plugins.entity.ts | 76 + .../db/entities/PushSubscriptions.entity.ts | 37 + .../ReservationDayPositions.entity.ts | 27 + .../entities/ReservationEndpoints.entity.ts | 41 + .../entities/ReservationTravelers.entity.ts | 26 + server/src/db/entities/Reservations.entity.ts | 106 + .../entities/RoadtripDayBoundaries.entity.ts | 43 + .../db/entities/RoadtripDayTracks.entity.ts | 28 + .../db/entities/RoadtripPreferences.entity.ts | 24 + server/src/db/entities/RoadtripVias.entity.ts | 36 + .../src/db/entities/RouteUsageDaily.entity.ts | 30 + .../src/db/entities/SchemaVersion.entity.ts | 17 + .../entities/SchoolHolidayCountries.entity.ts | 21 + .../entities/SchoolHolidayPeriods.entity.ts | 32 + .../entities/SchoolHolidayRegions.entity.ts | 28 + server/src/db/entities/Settings.entity.ts | 25 + server/src/db/entities/ShareTokens.entity.ts | 47 + server/src/db/entities/Tags.entity.ts | 33 + .../entities/TodoCategoryAssignees.entity.ts | 28 + server/src/db/entities/TodoItems.entity.ts | 44 + .../db/entities/TrekPhotoCacheMeta.entity.ts | 20 + server/src/db/entities/TrekPhotos.entity.ts | 67 + .../src/db/entities/TripAlbumLinks.entity.ts | 44 + .../db/entities/TripDocumentLinks.entity.ts | 88 + server/src/db/entities/TripFiles.entity.ts | 68 + .../db/entities/TripInviteTokens.entity.ts | 33 + server/src/db/entities/TripMembers.entity.ts | 33 + server/src/db/entities/TripPhotos.entity.ts | 41 + server/src/db/entities/Trips.entity.ts | 149 + .../entities/UserNoticeDismissals.entity.ts | 26 + server/src/db/entities/Users.entity.ts | 333 + .../entities/VacayCompanyHolidays.entity.ts | 27 + server/src/db/entities/VacayEntries.entity.ts | 34 + .../entities/VacayHolidayCalendars.entity.ts | 30 + .../db/entities/VacayPlanMembers.entity.ts | 31 + server/src/db/entities/VacayPlans.entity.ts | 61 + server/src/db/entities/VacayShares.entity.ts | 30 + .../src/db/entities/VacayUserColors.entity.ts | 28 + .../db/entities/VacayUserSettings.entity.ts | 27 + .../src/db/entities/VacayUserYears.entity.ts | 32 + server/src/db/entities/VacayYears.entity.ts | 23 + .../db/entities/VisitedCountries.entity.ts | 28 + .../src/db/entities/VisitedRegions.entity.ts | 30 + .../db/entities/WebauthnChallenges.entity.ts | 30 + .../db/entities/WebauthnCredentials.entity.ts | 42 + server/src/db/entities/index.ts | 258 + server/src/db/legacy-baseline.ts | 182 + server/src/db/migration-utils.ts | 161 + server/src/db/migrations.ts | 5484 ----------------- ...Migration20200101000000_baseline_schema.ts | 774 +++ ...200101000100_users_add_unsplash_api_key.ts | 13 + ...101000200_users_add_openweather_api_key.ts | 13 + ...00101000300_places_add_duration_minutes.ts | 13 + ...igration20200101000400_places_add_notes.ts | 13 + ...tion20200101000500_places_add_image_url.ts | 13 + ...0200101000600_places_add_transport_mode.ts | 13 + .../Migration20200101000700_days_add_title.ts | 13 + ...n20200101000800_reservations_add_status.ts | 13 + ...101000900_trip_files_add_reservation_id.ts | 18 + ...ion20200101001000_reservations_add_type.ts | 13 + ...ion20200101001100_trips_add_cover_image.ts | 13 + ...ration20200101001200_day_notes_add_icon.ts | 13 + ...ion20200101001300_trips_add_is_archived.ts | 13 + ...on20200101001400_categories_add_user_id.ts | 13 + ...igration20200101001500_users_add_avatar.ts | 13 + ...ration20200101001600_users_add_oidc_sub.ts | 13 + ...ion20200101001700_users_add_oidc_issuer.ts | 13 + ...tion20200101001800_users_add_last_login.ts | 13 + ...n20200101001900_create_budget_items_new.ts | 43 + ...1002000_day_accommodations_add_check_in.ts | 17 + ...ation20200101002100_places_add_end_time.ts | 13 + ..._day_assignments_add_reservation_status.ts | 31 + ...01002300_reservations_add_assignment_id.ts | 18 + ...01002400_create_assignment_participants.ts | 19 + ...ation20200101002500_create_collab_notes.ts | 69 + ...600_day_assignments_add_assignment_time.ts | 24 + ...200101002700_create_budget_item_members.ts | 24 + ...1002800_create_collab_message_reactions.ts | 23 + ...00101002900_collab_messages_add_deleted.ts | 13 + ...on20200101003000_trip_files_add_note_id.ts | 20 + ...0_reservations_add_reservation_end_time.ts | 13 + ...gration20200101003200_places_add_osm_id.ts | 13 + ...200101003300_trip_files_add_uploaded_by.ts | 22 + ...03400_reservations_add_accommodation_id.ts | 20 + ...tion20200101003500_create_invite_tokens.ts | 22 + ...ion20200101003600_users_add_mfa_enabled.ts | 15 + ...03700_create_packing_category_assignees.ts | 20 + ...20200101003800_create_packing_templates.ts | 40 + ...ation20200101003900_create_packing_bags.ts | 32 + ...20200101004000_create_visited_countries.ts | 20 + ...ration20200101004100_create_bucket_list.ts | 23 + ...0101004200_vacay_plans_add_weekend_days.ts | 13 + ...tion20200101004300_users_add_immich_url.ts | 35 + ...gration20200101004400_create_file_links.ts | 24 + ...4500_reservations_add_day_plan_position.ts | 13 + ...004600_budget_items_add_paid_by_user_id.ts | 13 + ...00101004700_bucket_list_add_target_date.ts | 13 + ...1004800_create_notification_preferences.ts | 26 + ...ion_preferences_add_notify_vacay_invite.ts | 39 + ...ation20200101005000_create_share_tokens.ts | 25 + ...200101005100_share_tokens_add_share_map.ts | 21 + ...igration20200101005200_create_audit_log.ts | 24 + ...200101005300_users_add_mfa_backup_codes.ts | 13 + ...0200101005400_mcp_long_lived_api_tokens.ts | 24 + ...Migration20200101005500_mcp_addon_entry.ts | 19 + ...ation20200101005600_index_on_mcp_tokens.ts | 14 + ...00_ensure_mcp_addon_type_is_integration.ts | 15 + ...0200101005800_places_add_route_geometry.ts | 13 + ...01005900_users_add_must_change_password.ts | 13 + ...n20200101010000_trips_add_reminder_days.ts | 13 + ...t_any_plaintext_oidc_client_secret_left.ts | 20 + ...encrypt_any_plaintext_smtp_pass_left_in.ts | 20 + ...ypt_any_plaintext_immich_api_key_values.ts | 23 + ...101010400_budget_items_add_expense_date.ts | 13 + ...n20200101010500_create_trip_album_links.ts | 26 + ...tion20200101010600_create_notifications.ts | 42 + ...ration20200101010700_create_trip_photos.ts | 54 + ...n20200101010800_create_trip_album_links.ts | 56 + ...on20200101010900_users_add_synology_url.ts | 19 + .../Migration20200101011000_step_070.ts | 97 + .../Migration20200101011100_step_071.ts | 18 + .../Migration20200101011200_step_072.ts | 22 + ...101011300_trip_photos_add_album_link_id.ts | 20 + .../Migration20200101011400_todo_items.ts | 40 + ...Migration20200101011500_backfill_addons.ts | 13 + ...ace_region_cache_for_sub_national_atlas.ts | 25 + ...on20200101011700_create_visited_regions.ts | 24 + ...er_per_channel_notification_preferences.ts | 69 + ..._the_old_notification_preferences_table.ts | 14 + ..._add_reservation_id_to_budget_items_for.ts | 20 + ...0_add_quantity_to_packing_items_user_id.ts | 43 + ...ay_positions_for_multi_day_reservations.ts | 55 + ...20200101012300_budget_category_ordering.ts | 39 + ...n20200101012400_naver_list_import_addon.ts | 19 + .../Migration20200101012500_oauth_2.ts | 61 + ...oken_rotation_chain_tracking_for_replay.ts | 22 + ...t_support_for_browser_initiated_dynamic.ts | 17 + ...ration20200101012800_make_oauth_clients.ts | 42 + ...00_add_otp_field_skip_ssl_column_device.ts | 39 + .../Migration20200101013000_journey_addon.ts | 121 + .../Migration20200101013100_journal.ts | 40 + ...ulti_trip_support_sharing_collaboration.ts | 53 + ...Migration20200101013300_journey_rebuild.ts | 226 + .../Migration20200101013400_journey_photos.ts | 54 + ...ation20200101013500_journey_cover_image.ts | 15 + ...101013600_pros_cons_for_journey_entries.ts | 15 + ...tion20200101013700_journey_share_tokens.ts | 29 + ...20200101013800_vacay_week_start_setting.ts | 15 + ...nified_photo_provider_abstraction_layer.ts | 136 + ...e_skeletons_per_user_setting_on_journey.ts | 20 + ...otency_keys_for_offline_mutation_replay.ts | 27 + ...200_enable_naver_list_import_by_default.ts | 14 + ...14300_add_check_in_end_column_for_check.ts | 15 + .../Migration20200101014400_system_notices.ts | 27 + ...support_for_synology_shared_album_links.ts | 17 + ..._google_place_photo_disk_cache_registry.ts | 21 + ...4700_persistent_place_details_row_cache.ts | 23 + ...ill_expired_signed_google_photo_urls_to.ts | 28 + ...ache_metadata_for_remote_provider_photo.ts | 24 + ...ion20200101015000_reservation_endpoints.ts | 36 + ...00101015100_reservations_add_end_day_id.ts | 47 + ...0200101015200_opt_in_immich_auto_upload.ts | 17 + ...expose_immich_auto_upload_toggle_in_the.ts | 27 + ...0101015400_rfc_8707_resource_indicators.ts | 15 + .../Migration20200101015500_password_reset.ts | 33 + ...n20200101015600_todo_due_date_reminders.ts | 17 + ...on20200101015700_security_audit_batch_1.ts | 56 + ...0_widen_idempotency_keys_primary_key_to.ts | 42 + ...0_sec_h6_revoke_all_oauth_tokens_issued.ts | 20 + ...t_null_constraint_on_day_accommodations.ts | 48 + ...0_null_out_proxy_image_url_entries_that.ts | 30 + ...lear_legacy_google_photo_urls_missed_by.ts | 30 + ...20200101020300_journey_gallery_refactor.ts | 104 + ...1020400_correct_stale_day_id_end_day_id.ts | 74 + ..._make_sort_order_authoritative_within_a.ts | 32 + ...0600_swap_inverted_start_day_id_end_day.ts | 21 + ...20700_prepare_migration_to_nest_typeorm.ts | 21 + ...ailing_whitespace_from_stored_usernames.ts | 91 + ...0200101020900_create_schema_version_new.ts | 24 + .../Migration20200101021000_oauth_2.ts | 23 + ..._stale_atlas_cache_rows_for_territories.ts | 38 + .../Migration20200101021200_costs_rework.ts | 65 + ...ename_the_budget_planner_addon_to_costs.ts | 18 + ...key_support_per_user_credentials_single.ts | 46 + ...dropped_natural_earth_for_geoboundaries.ts | 131 + .../Migration20200101021600_step_136.ts | 21 + ...on20200101021700_users_add_airtrail_url.ts | 22 + ...021800_reservations_add_external_source.ts | 27 + ...021900_users_add_airtrail_write_enabled.ts | 13 + ...google_maps_feature_ids_separately_from.ts | 15 + ...0_remember_the_app_version_a_notice_was.ts | 16 + ...01022200_budget_item_members_add_amount.ts | 13 + ...tion20200101022300_calendar_feed_tokens.ts | 25 + ...cy_token_for_offline_conflict_detection.ts | 24 + .../Migration20200101022500_video_support.ts | 19 + ...ion20200101022600_dedicated_booking_url.ts | 16 + ...ion20200101022700_private_packing_items.ts | 24 + .../Migration20200101022800_guest_members.ts | 18 + ...200101022900_three_tier_packing_sharing.ts | 36 + ...gration20200101023000_collections_addon.ts | 89 + ...added_links_on_collections_saved_places.ts | 17 + ..._per_member_permission_role_on_a_shared.ts | 16 + ...ion20200101023300_per_trip_invite_links.ts | 28 + ...ptional_trip_binding_on_an_admin_invite.ts | 22 + ...on20200101023500_plugin_system_scaffold.ts | 83 + ...3600_separate_the_admin_s_on_off_intent.ts | 20 + ...ation20200101023700_plugin_capabilities.ts | 17 + ...01023800_tofu_pin_for_a_plugin_s_author.ts | 18 + ...23900_hash_chained_capability_audit_log.ts | 39 + ...0101024000_per_collection_custom_labels.ts | 41 + ..._plugin_owned_metadata_on_core_entities.ts | 34 + ...1024200_freeze_the_fx_rate_on_settle_up.ts | 23 + ...300_1446_guests_are_per_trip_people_but.ts | 18 + ...ation20200101024400_plugin_dependencies.ts | 19 + ...20200101024500_per_user_plugin_settings.ts | 26 + ...0101024600_host_brokered_outbound_oauth.ts | 38 + ...00101024700_persistent_plugin_scheduler.ts | 32 + ...200101024800_durable_gdpr_erasure_queue.ts | 30 + ...stones_for_atlas_countries_the_user_has.ts | 29 + ...ator_supplied_egress_hosts_for_a_plugin.ts | 37 + ...age_action_buttons_a_plugin_contributes.ts | 26 + ...25200_why_a_plugin_s_update_was_refused.ts | 27 + ...300_the_semver_range_of_trek_versions_a.ts | 20 + ...ace_regions_is_a_re_derivable_nominatim.ts | 27 + ...mbstones_for_atlas_regions_the_user_has.ts | 32 + ...ration20200101025600_half_vacation_days.ts | 18 + ...025700_read_only_vacay_calendar_sharing.ts | 29 + ...00101025800_collaborative_place_ratings.ts | 42 + ...n20200101025900_per_segment_travel_mode.ts | 21 + ...ol_holidays_are_a_visual_vacay_calendar.ts | 23 + .../Migration20200101030100_1517.ts | 25 + .../Migration20200101030200_comp_flex_days.ts | 17 + ...200101030300_configurable_vacation_year.ts | 25 + ...n20200101030400_manual_gpx_track_colour.ts | 18 + ...the_three_places_feature_toggles_before.ts | 21 + ...00_free_the_note_column_on_budget_items.ts | 27 + .../Migration20200101030700_note_colours.ts | 18 + ...r_segment_travel_mode_for_boundary_legs.ts | 25 + .../Migration20200101030900_1298.ts | 25 + ...eservation_day_positions_row_only_makes.ts | 28 + .../Migration20200101031100_1939.ts | 58 + ...00_capture_metadata_on_the_photo_itself.ts | 26 + ...1300_a_journey_shared_while_the_trip_is.ts | 19 + ...gration20200101031400_trek_studio_books.ts | 37 + ...ritain_gained_its_counties_and_boroughs.ts | 79 + ...Migration20200101031600_storage_slice_2.ts | 25 + ...give_back_the_notes_the_ticketjson_step.ts | 43 + ...liberate_non_latest_plugin_install_sets.ts | 19 + ...s_an_automated_ingest_parked_for_review.ts | 22 + ...separate_an_integration_key_from_an_mcp.ts | 24 + ...00_naver_list_import_was_typed_trip_but.ts | 21 + ...32200_whether_a_journey_s_map_draws_the.ts | 25 + ...n20200101032300_settings_field_defaults.ts | 19 + ...2400_settings_form_actions_gain_a_scope.ts | 21 + ...101032500_a_journal_entry_that_is_not_a.ts | 24 + ..._place_shadow_log_which_search_result_a.ts | 40 + ...0101032700_what_kind_of_stop_a_place_is.ts | 25 + ...0101032800_points_a_day_s_drive_is_made.ts | 38 + ...2900_which_imported_track_a_day_s_drive.ts | 33 + ..._rename_the_misnamed_guangdong_province.ts | 45 + ...01033100_let_a_file_hang_off_an_expense.ts | 38 + ..._chat_message_an_uploaded_image_belongs.ts | 27 + ...300_links_somebody_shared_with_the_trip.ts | 32 + ...tion20200101033400_route_usage_counters.ts | 36 + ...33500_how_full_this_stop_fills_the_tank.ts | 23 + ...1033600_create_school_holiday_countries.ts | 36 + ...00101033700_day_assignments_add_end_day.ts | 13 + ...01033800_create_roadtrip_day_boundaries.ts | 21 + ...00101033900_create_roadtrip_preferences.ts | 24 + ...01034000_a_settle_up_payment_s_date_was.ts | 20 + .../Migration20200101034100_amap.ts | 29 + ...ation20200101034200_dawarich_connection.ts | 33 + ...200101034300_reviewed_visit_suggestions.ts | 58 + ...0_bucket_list_entries_can_now_be_ticked.ts | 18 + ...034500_where_an_atlas_country_came_from.ts | 18 + ...01034600_per_key_read_scopes_for_api_v1.ts | 24 + ...1034700_where_a_place_came_from_when_it.ts | 20 + ...034800_provenance_for_a_day_stop_that_a.ts | 20 + ...e_every_stay_booked_before_this_release.ts | 26 + ...035000_the_same_sweep_once_more_for_the.ts | 27 + ...suggestion_the_traveller_has_waved_away.ts | 20 + ...e_country_an_entry_happened_in_resolved.ts | 18 + ...00_which_of_the_optional_entry_fields_a.ts | 24 + ...00101035400_let_go_of_a_booking_the_day.ts | 39 + ...500_a_journal_skeleton_belongs_to_a_day.ts | 55 + ...5600_document_providers_part_1_of_3_the.ts | 69 + ...5700_document_providers_part_2_of_3_the.ts | 93 + ...5800_document_providers_part_3_of_3_the.ts | 74 + ...cument_providers_when_trek_itself_put_a.ts | 25 + ...000_trips_longer_than_a_year_lost_every.ts | 74 + ...he_road_trip_day_boundaries_carried_the.ts | 35 + ...rek_photo_cache_meta_cache_key_not_null.ts | 41 + ...ooked_night_finally_took_the_seat_a_new.ts | 40 + ...learns_the_switch_synology_airtrail_and.ts | 39 + ...0500_a_place_that_moves_takes_its_atlas.ts | 32 + ...01040600_web_push_one_row_per_browser_a.ts | 40 + ...40700_the_2527_trigger_once_more_for_an.ts | 18 + ...200101040800_a_stop_kept_on_the_day_but.ts | 16 + ...01040900_how_many_of_an_item_are_packed.ts | 17 + ...041000_a_bucket_list_wish_for_one_state.ts | 16 + ...00_half_company_holidays_0_5_covers_the.ts | 16 + ...200_a_journey_entry_still_being_written.ts | 17 + ...41300_two_narrower_ways_to_share_a_trip.ts | 18 + ...101041400_a_note_on_a_settle_up_payment.ts | 16 + ...0101041500_google_api_calls_per_utc_day.ts | 17 + ..._templates_remember_what_an_item_weighs.ts | 19 + ...01041700_a_journey_s_state_as_its_owner.ts | 17 + ...20200101041800_a_place_s_e_mail_and_its.ts | 17 + ...1041900_a_journey_that_puts_an_entry_on.ts | 18 + server/src/db/nul-safe-sqlite-platform.ts | 109 + server/src/db/orm-driver.ts | 101 + server/src/db/orm.ts | 104 + .../src/db/repositories/Addons.repository.ts | 93 + .../db/repositories/AppSettings.repository.ts | 124 + .../AssignmentParticipants.repository.ts | 162 + .../db/repositories/AuditLog.repository.ts | 128 + .../db/repositories/BucketList.repository.ts | 333 + .../BudgetCategoryOrder.repository.ts | 74 + .../BudgetItemMembers.repository.ts | 182 + .../BudgetItemPayers.repository.ts | 95 + .../db/repositories/BudgetItems.repository.ts | 542 ++ .../BudgetSettlements.repository.ts | 231 + .../db/repositories/Categories.repository.ts | 148 + .../db/repositories/CollabLinks.repository.ts | 93 + .../CollabMessageReactions.repository.ts | 74 + .../repositories/CollabMessages.repository.ts | 229 + .../db/repositories/CollabNotes.repository.ts | 256 + .../CollabPollVotes.repository.ts | 51 + .../db/repositories/CollabPolls.repository.ts | 77 + .../CollectionLabels.repository.ts | 144 + .../CollectionMembers.repository.ts | 197 + .../CollectionPlaceRatings.repository.ts | 72 + .../CollectionPlaces.repository.ts | 486 ++ .../db/repositories/Collections.repository.ts | 504 ++ .../DawarichConnections.repository.ts | 201 + .../DawarichVisitSuggestions.repository.ts | 399 ++ .../DayAccommodations.repository.ts | 540 ++ .../repositories/DayAssignments.repository.ts | 989 +++ .../db/repositories/DayNotes.repository.ts | 209 + server/src/db/repositories/Days.repository.ts | 379 ++ server/src/db/repositories/DemoRepository.ts | 288 + .../DocumentConnections.repository.ts | 195 + .../DocumentProviderFields.repository.ts | 57 + .../DocumentProviders.repository.ts | 113 + .../DocumentSyncItems.repository.ts | 670 ++ .../db/repositories/FileLinks.repository.ts | 175 + .../repositories/GoogleApiUsage.repository.ts | 61 + .../GooglePlacePhotoMeta.repository.ts | 72 + .../HiddenCountries.repository.ts | 38 + .../repositories/HiddenRegions.repository.ts | 39 + .../IdempotencyKeys.repository.ts | 75 + .../repositories/InviteTokens.repository.ts | 213 + .../repositories/JourneyBooks.repository.ts | 105 + .../JourneyContributors.repository.ts | 149 + .../repositories/JourneyEntries.repository.ts | 749 +++ .../JourneyEntryPhotos.repository.ts | 250 + .../repositories/JourneyPhotos.repository.ts | 444 ++ .../JourneyShareTokens.repository.ts | 168 + .../repositories/JourneyTrips.repository.ts | 170 + .../db/repositories/Journeys.repository.ts | 257 + .../db/repositories/MaintenanceRepository.ts | 110 + .../db/repositories/McpTokens.repository.ts | 391 ++ .../db/repositories/Migrations.repository.ts | 4 + ...tificationChannelPreferences.repository.ts | 71 + .../repositories/Notifications.repository.ts | 259 + .../repositories/OauthClients.repository.ts | 331 + .../repositories/OauthConsents.repository.ts | 113 + .../db/repositories/OauthTokens.repository.ts | 621 ++ .../db/repositories/PackingBags.repository.ts | 264 + .../PackingCategoryAssignees.repository.ts | 94 + .../PackingItemContributors.repository.ts | 77 + .../repositories/PackingItems.repository.ts | 488 ++ .../PackingTemplateCategories.repository.ts | 72 + .../PackingTemplateItems.repository.ts | 149 + .../PackingTemplates.repository.ts | 140 + .../PasswordResetTokens.repository.ts | 116 + .../PhotoProviderFields.repository.ts | 99 + .../repositories/PhotoProviders.repository.ts | 95 + .../src/db/repositories/Photos.repository.ts | 31 + .../PlaceDetailsCache.repository.ts | 35 + .../repositories/PlaceRatings.repository.ts | 111 + .../repositories/PlaceRegions.repository.ts | 191 + .../PlaceShadowPicks.repository.ts | 139 + .../src/db/repositories/Places.repository.ts | 1387 +++++ .../repositories/PluginActions.repository.ts | 58 + .../PluginCapabilityAudit.repository.ts | 181 + .../PluginEgressHosts.repository.ts | 39 + .../PluginEntityMetadata.repository.ts | 101 + .../repositories/PluginErrorLog.repository.ts | 57 + .../PluginMetaMigrations.repository.ts | 16 + .../PluginOauthState.repository.ts | 91 + .../PluginOauthTokens.repository.ts | 129 + .../PluginScheduledTasks.repository.ts | 141 + .../PluginSettingsFields.repository.ts | 179 + .../PluginUserConfig.repository.ts | 51 + .../PluginUserErasureQueue.repository.ts | 65 + .../src/db/repositories/Plugins.repository.ts | 617 ++ .../PushSubscriptions.repository.ts | 177 + .../ReservationDayPositions.repository.ts | 79 + .../ReservationEndpoints.repository.ts | 286 + .../ReservationTravelers.repository.ts | 77 + .../repositories/Reservations.repository.ts | 1756 ++++++ .../RoadtripDayBoundaries.repository.ts | 101 + .../RoadtripDayTracks.repository.ts | 71 + .../RoadtripPreferences.repository.ts | 35 + .../repositories/RoadtripVias.repository.ts | 175 + .../RouteUsageDaily.repository.ts | 135 + .../repositories/SchemaVersion.repository.ts | 4 + .../SchoolHolidayCountries.repository.ts | 69 + .../SchoolHolidayPeriods.repository.ts | 62 + .../SchoolHolidayRegions.repository.ts | 140 + .../db/repositories/Settings.repository.ts | 92 + .../db/repositories/ShareTokens.repository.ts | 170 + server/src/db/repositories/Tags.repository.ts | 275 + .../TodoCategoryAssignees.repository.ts | 79 + .../db/repositories/TodoItems.repository.ts | 277 + .../TrekPhotoCacheMeta.repository.ts | 50 + .../db/repositories/TrekPhotos.repository.ts | 157 + .../repositories/TripAlbumLinks.repository.ts | 165 + .../TripDocumentLinks.repository.ts | 257 + .../db/repositories/TripFiles.repository.ts | 272 + .../TripInviteTokens.repository.ts | 117 + .../db/repositories/TripMembers.repository.ts | 315 + .../db/repositories/TripPhotos.repository.ts | 212 + .../src/db/repositories/Trips.repository.ts | 1115 ++++ .../UserNoticeDismissals.repository.ts | 74 + .../src/db/repositories/Users.repository.ts | 1644 +++++ .../VacayCompanyHolidays.repository.ts | 72 + .../repositories/VacayEntries.repository.ts | 213 + .../VacayHolidayCalendars.repository.ts | 127 + .../VacayPlanMembers.repository.ts | 190 + .../db/repositories/VacayPlans.repository.ts | 123 + .../db/repositories/VacayShares.repository.ts | 180 + .../VacayUserColors.repository.ts | 52 + .../VacayUserSettings.repository.ts | 48 + .../repositories/VacayUserYears.repository.ts | 104 + .../db/repositories/VacayYears.repository.ts | 55 + .../VisitedCountries.repository.ts | 123 + .../repositories/VisitedRegions.repository.ts | 94 + .../WebauthnChallenges.repository.ts | 103 + .../WebauthnCredentials.repository.ts | 262 + .../repositories/_shared/collection-role.ts | 87 + .../db/repositories/_shared/owned-lookup.ts | 103 + .../_shared/packing-visibility.ts | 149 + .../db/repositories/_shared/presence-set.ts | 35 + .../_shared/reservation-travelers-owns.ts | 138 + .../_shared/reservation-visibility.ts | 166 + server/src/db/repositories/_shared/rows.ts | 40 + .../repositories/_shared/trek-repository.ts | 270 + .../db/repositories/_shared/trip-access.ts | 41 + server/src/db/reseat-booked-nights.ts | 51 +- server/src/db/schema-snapshot.ts | 47 + server/src/db/schema.ts | 619 -- server/src/db/seeders/AddonSeeder.ts | 163 + server/src/db/seeders/AdminSeeder.ts | 104 + server/src/db/seeders/CategorySeeder.ts | 40 + server/src/db/seeders/DatabaseSeeder.ts | 21 + .../src/db/seeders/DocumentProviderSeeder.ts | 32 + server/src/db/seeders/PhotoProviderSeeder.ts | 193 + server/src/db/seeders/SchemaVersionSeeder.ts | 32 + server/src/db/seeds.ts | 189 - server/src/db/types/db-timestamp.type.ts | 68 + server/src/db/types/index.ts | 1 + server/src/demo/demo-reset.ts | 149 +- server/src/demo/demo-seed.ts | 245 +- server/src/index.ts | 3 +- server/src/mcp/tools.ts | 4 +- server/src/mikro-orm.config.ts | 30 + server/src/nest-mcp/registry.ts | 27 +- server/src/nest-mcp/types.ts | 4 +- server/src/nest/README.md | 32 +- .../accommodations-domain.module.ts | 26 +- .../accommodations.controller.ts | 30 +- .../nest/accommodations/accommodations.mcp.ts | 78 +- .../accommodations/accommodations.module.ts | 3 +- .../nest/accommodations/accommodations.rpc.ts | 32 +- .../accommodations/accommodations.service.ts | 355 +- server/src/nest/accommodations/night-seat.ts | 219 +- server/src/nest/addons/addon-gate.ts | 6 +- server/src/nest/addons/addon.guard.ts | 4 +- server/src/nest/addons/addons.controller.ts | 2 +- server/src/nest/addons/addons.mcp.ts | 4 +- server/src/nest/addons/addons.module.ts | 15 + server/src/nest/addons/addons.service.ts | 224 +- server/src/nest/addons/mcp-addon-gate.ts | 15 +- server/src/nest/admin/admin.controller.ts | 140 +- server/src/nest/admin/admin.module.ts | 32 +- server/src/nest/admin/admin.service.ts | 396 +- server/src/nest/admin/demo-reset.job.ts | 9 +- server/src/nest/airports/airports.module.ts | 17 +- server/src/nest/airports/airports.service.ts | 74 +- server/src/nest/app.module.ts | 240 +- .../assignments/assignments-domain.module.ts | 28 +- .../assignments/assignments.controller.ts | 97 +- .../src/nest/assignments/assignments.mcp.ts | 132 +- .../nest/assignments/assignments.service.ts | 472 +- server/src/nest/assignments/itinerary.rpc.ts | 22 +- server/src/nest/atlas/atlas-geo.ts | 5 +- server/src/nest/atlas/atlas.controller.ts | 42 +- server/src/nest/atlas/atlas.mcp.ts | 38 +- server/src/nest/atlas/atlas.module.ts | 30 +- server/src/nest/atlas/atlas.rpc.ts | 54 +- server/src/nest/atlas/atlas.service.ts | 614 +- .../nest/atlas/place-regions-repair.job.ts | 21 +- .../src/nest/atlas/public-stats.controller.ts | 8 +- .../src/nest/atlas/travel-stats.controller.ts | 2 +- server/src/nest/audit/audit.module.ts | 4 + server/src/nest/audit/audit.service.ts | 66 +- .../src/nest/auth/auth-public.controller.ts | 42 +- server/src/nest/auth/auth.controller.ts | 98 +- server/src/nest/auth/auth.module.ts | 54 +- server/src/nest/auth/auth.service.ts | 444 +- server/src/nest/auth/cookie-auth.guard.ts | 13 +- server/src/nest/auth/global-auth.guard.ts | 19 +- server/src/nest/auth/jwt-auth.guard.ts | 39 +- server/src/nest/auth/jwt-verify.ts | 18 +- server/src/nest/auth/mfa-policy.guard.ts | 33 +- server/src/nest/auth/optional-jwt.guard.ts | 11 +- server/src/nest/auth/passkey-enabled.guard.ts | 4 +- server/src/nest/auth/passkey.controller.ts | 20 +- server/src/nest/auth/passkey.service.ts | 212 +- .../nest/auth/registration-invites.service.ts | 90 +- .../nest/auth/session-renewal.interceptor.ts | 8 +- server/src/nest/auth/user-cleanup.service.ts | 149 +- server/src/nest/auth/user-profile.service.ts | 206 +- .../src/nest/auth/webauthn-config.service.ts | 21 +- server/src/nest/backup/auto-backup.job.ts | 6 +- server/src/nest/backup/backup-archive.ts | 3 +- server/src/nest/backup/backup.controller.ts | 12 +- server/src/nest/backup/backup.impl.ts | 99 +- .../booking-import/booking-import.module.ts | 20 +- .../booking-import/booking-import.service.ts | 55 +- .../booking-import/import-jobs.service.ts | 19 +- server/src/nest/budget/budget.controller.ts | 123 +- server/src/nest/budget/budget.mcp.ts | 173 +- server/src/nest/budget/budget.module.ts | 18 +- server/src/nest/budget/budget.service.ts | 873 ++- server/src/nest/budget/costs.rpc.ts | 73 +- .../src/nest/budget/exchange-rates.service.ts | 4 +- server/src/nest/calendar/calendar.module.ts | 15 +- server/src/nest/calendar/calendar.service.ts | 82 +- .../nest/categories/categories.controller.ts | 20 +- server/src/nest/categories/categories.mcp.ts | 42 +- .../src/nest/categories/categories.module.ts | 12 +- server/src/nest/categories/categories.rpc.ts | 4 +- .../src/nest/categories/categories.service.ts | 106 +- server/src/nest/collab/collab.controller.ts | 90 +- server/src/nest/collab/collab.mcp.ts | 98 +- server/src/nest/collab/collab.module.ts | 13 + server/src/nest/collab/collab.rpc.ts | 54 +- server/src/nest/collab/collab.service.ts | 514 +- .../collections/collections.controller.ts | 64 +- .../src/nest/collections/collections.mcp.ts | 112 +- .../nest/collections/collections.module.ts | 19 +- .../src/nest/collections/collections.rpc.ts | 41 +- .../nest/collections/collections.service.ts | 1207 ++-- server/src/nest/common/demo-write.ts | 17 +- server/src/nest/common/demo.module.ts | 22 + server/src/nest/common/demo.service.ts | 48 + .../nest/common/idempotency-cleanup.job.ts | 15 +- server/src/nest/common/idempotency-cleanup.ts | 23 +- .../nest/common/idempotency.interceptor.ts | 154 +- server/src/nest/common/row-id.ts | 120 + server/src/nest/common/rowShape.ts | 21 +- server/src/nest/database/database.module.ts | 17 - server/src/nest/database/database.service.ts | 81 - server/src/nest/database/database.tokens.ts | 6 - server/src/nest/database/orm.module.ts | 19 + server/src/nest/database/request-context.ts | 19 + server/src/nest/database/unit-of-work.ts | 28 + .../nest/day-notes/day-notes.controller.ts | 22 +- server/src/nest/day-notes/day-notes.mcp.ts | 34 +- server/src/nest/day-notes/day-notes.module.ts | 17 +- server/src/nest/day-notes/day-notes.rpc.ts | 30 +- .../src/nest/day-notes/day-notes.service.ts | 92 +- server/src/nest/days/day-removal.service.ts | 125 +- server/src/nest/days/days.controller.ts | 42 +- server/src/nest/days/days.mcp.ts | 62 +- server/src/nest/days/days.module.ts | 41 +- server/src/nest/days/days.rpc.ts | 30 +- server/src/nest/days/days.service.ts | 571 +- .../nest/doc-sync/doc-sync-config.service.ts | 428 +- .../doc-sync/doc-sync-webhook.controller.ts | 56 +- .../src/nest/doc-sync/doc-sync.controller.ts | 165 +- server/src/nest/doc-sync/doc-sync.job.ts | 32 +- server/src/nest/doc-sync/doc-sync.mcp.ts | 24 +- server/src/nest/doc-sync/doc-sync.module.ts | 33 +- server/src/nest/doc-sync/doc-sync.service.ts | 650 +- server/src/nest/doc-sync/document-provider.ts | 2 +- .../providers/synology-drive.client.ts | 26 +- server/src/nest/feeds/feeds.controller.ts | 49 +- server/src/nest/feeds/feeds.mcp.ts | 60 +- server/src/nest/feeds/feeds.module.ts | 28 +- server/src/nest/feeds/feeds.service.ts | 123 +- .../nest/files/allowed-file-types.module.ts | 7 +- .../nest/files/allowed-file-types.service.ts | 18 +- .../nest/files/files-download.controller.ts | 6 +- server/src/nest/files/files.controller.ts | 126 +- server/src/nest/files/files.mcp.ts | 56 +- server/src/nest/files/files.module.ts | 16 + server/src/nest/files/files.rpc.ts | 49 +- server/src/nest/files/files.service.ts | 337 +- .../google-quota/google-quota.controller.ts | 8 +- .../nest/google-quota/google-quota.module.ts | 8 +- .../nest/google-quota/google-quota.service.ts | 57 +- server/src/nest/health/features.controller.ts | 4 +- server/src/nest/help/wiki.ts | 2 +- .../nest/integrations/airtrail-core.module.ts | 21 +- .../integrations/airtrail-import.service.ts | 59 +- .../integrations/airtrail-link.service.ts | 77 +- .../nest/integrations/airtrail-sync.job.ts | 26 +- .../integrations/airtrail-sync.service.ts | 44 +- .../src/nest/integrations/airtrail.module.ts | 23 +- .../src/nest/integrations/airtrail.service.ts | 60 +- .../dawarich-suggestions.service.ts | 276 +- .../nest/integrations/dawarich-sync.job.ts | 29 +- .../integrations/dawarich-sync.service.ts | 244 +- .../integrations/dawarich-tracks.service.ts | 17 +- .../nest/integrations/dawarich.controller.ts | 25 +- server/src/nest/integrations/dawarich.mcp.ts | 26 +- .../src/nest/integrations/dawarich.module.ts | 21 + .../src/nest/integrations/dawarich.service.ts | 130 +- server/src/nest/journey/journal-rpc.module.ts | 14 +- server/src/nest/journey/journal.rpc.ts | 63 +- .../src/nest/journey/journey-book.service.ts | 167 +- .../src/nest/journey/journey-domain.module.ts | 44 +- .../nest/journey/journey-domain.service.ts | 1903 +++--- .../journey/journey-photo-capture.service.ts | 40 +- .../nest/journey/journey-public.controller.ts | 8 +- .../src/nest/journey/journey-share.service.ts | 233 +- server/src/nest/journey/journey.controller.ts | 207 +- server/src/nest/journey/journey.mcp.ts | 171 +- server/src/nest/journey/journey.module.ts | 12 +- server/src/nest/journey/journey.service.ts | 20 +- .../src/nest/llm-parse/llm-config.resolver.ts | 45 +- server/src/nest/llm-parse/llm-parse.module.ts | 11 +- .../src/nest/llm-parse/llm-parse.service.ts | 12 +- server/src/nest/maps/maps.controller.ts | 6 +- server/src/nest/maps/maps.mcp.ts | 2 +- server/src/nest/maps/maps.module.ts | 15 +- server/src/nest/maps/maps.service.ts | 177 +- .../src/nest/mcp-shared/mcp-shared.module.ts | 9 +- .../mcp-shared/mcp-tool-guards.service.ts | 34 +- .../mcp-transport/mcp-transport.service.ts | 37 +- server/src/nest/memories/immich.controller.ts | 8 +- server/src/nest/memories/immich.service.ts | 66 +- .../src/nest/memories/journey-thumbs.job.ts | 8 +- .../nest/memories/memories-access.service.ts | 175 +- server/src/nest/memories/memories.mcp.ts | 34 +- server/src/nest/memories/memories.module.ts | 31 +- server/src/nest/memories/memories.service.ts | 6 +- .../photo-capture-backfill.service.ts | 10 +- .../nest/memories/photo-resolver.service.ts | 12 +- .../src/nest/memories/synology.controller.ts | 4 +- server/src/nest/memories/synology.service.ts | 67 +- server/src/nest/memories/thumbnail.service.ts | 24 +- .../src/nest/memories/trek-photo-cache.job.ts | 9 +- .../nest/memories/trek-photo-cache.service.ts | 40 +- .../nest/memories/unified-memories.service.ts | 183 +- .../src/nest/memories/unified.controller.ts | 22 +- .../nest/notifications/channel-registry.ts | 14 +- .../nest/notifications/channels/builtins.ts | 24 +- .../notifications/mailer/mailer.module.ts | 13 + .../notifications/mailer/mailer.service.ts | 73 +- .../nest/notifications/notification-events.ts | 4 +- .../notification-preferences.service.ts | 178 +- .../notifications/notifications.controller.ts | 50 +- .../nest/notifications/notifications.mcp.ts | 18 +- .../notifications/notifications.module.ts | 41 +- .../notifications/notifications.service.ts | 260 +- .../push/push-subscriptions.service.ts | 112 +- .../notifications/push/push.controller.ts | 18 +- .../notifications/push/vapid-keys.service.ts | 77 +- .../notifications/reminder-jobs.service.ts | 125 +- .../storage-health-notifier.service.ts | 61 +- .../notifications/transports/ntfy.service.ts | 47 +- .../transports/web-push.service.ts | 30 +- .../transports/webhook.service.ts | 26 +- server/src/nest/oauth/oauth-api.controller.ts | 54 +- .../src/nest/oauth/oauth-public.controller.ts | 44 +- server/src/nest/oauth/oauth-sdk.provider.ts | 16 +- server/src/nest/oauth/oauth.helpers.ts | 32 - server/src/nest/oauth/oauth.module.ts | 17 +- server/src/nest/oauth/oauth.service.ts | 314 +- server/src/nest/oidc/oidc.controller.ts | 26 +- server/src/nest/oidc/oidc.module.ts | 6 +- server/src/nest/oidc/oidc.service.ts | 206 +- .../admin-packing-templates.controller.ts | 48 +- server/src/nest/packing/packing.controller.ts | 168 +- server/src/nest/packing/packing.mcp.ts | 144 +- server/src/nest/packing/packing.module.ts | 18 +- server/src/nest/packing/packing.rpc.ts | 55 +- server/src/nest/packing/packing.service.ts | 689 +-- .../src/nest/permissions/permissions-cache.ts | 13 +- .../nest/permissions/permissions.module.ts | 7 +- .../nest/permissions/permissions.service.ts | 81 +- .../src/nest/permissions/trip-access.guard.ts | 24 +- .../src/nest/permissions/trip-owner.guard.ts | 20 +- server/src/nest/permissions/trip.decorator.ts | 2 +- server/src/nest/photos/photos.controller.ts | 10 +- server/src/nest/photos/photos.service.ts | 2 +- .../photos/trek-photo-registration.service.ts | 164 + server/src/nest/photos/trek-photos.module.ts | 17 +- .../src/nest/photos/trek-photos.repository.ts | 147 - .../place-enrichment.module.ts | 9 +- .../place-enrichment.service.ts | 51 +- .../place-photos/place-photo-cache.job.ts | 8 +- .../place-photos/place-photo-cache.service.ts | 86 +- .../nest/place-photos/place-photos.module.ts | 13 +- .../place-shadow/place-shadow.controller.ts | 16 +- .../src/nest/place-shadow/place-shadow.job.ts | 4 +- .../nest/place-shadow/place-shadow.module.ts | 9 +- .../nest/place-shadow/place-shadow.service.ts | 117 +- server/src/nest/places/place-image.ts | 38 +- server/src/nest/places/places.controller.ts | 98 +- server/src/nest/places/places.helpers.ts | 2 +- server/src/nest/places/places.mcp.ts | 112 +- server/src/nest/places/places.module.ts | 28 +- server/src/nest/places/places.rpc.ts | 28 +- server/src/nest/places/places.service.ts | 1059 ++-- .../src/nest/platform/discovery.controller.ts | 8 +- .../nest/platform/mcp-metadata.middleware.ts | 40 +- server/src/nest/platform/platform.routes.ts | 69 +- .../contributions/day-schedule.controller.ts | 16 +- .../contributions/day-tints.controller.ts | 16 +- .../journal-entry-rows.controller.ts | 21 +- .../contributions/map-layers.controller.ts | 8 +- .../contributions/map-markers.controller.ts | 8 +- .../contributions/pdf-sections.controller.ts | 8 +- .../contributions/place-details.controller.ts | 14 +- .../plugin-contributions.module.ts | 28 +- .../contributions/plugin-mcp-tools.service.ts | 23 +- .../plugins/contributions/plugin-pois.mcp.ts | 4 +- .../contributions/plugin-pois.service.ts | 20 +- .../contributions/plugin-route-normalize.ts | 20 +- .../contributions/plugin-routes.controller.ts | 13 +- .../trip-card-contributions.controller.ts | 20 +- .../contributions/trip-warnings.controller.ts | 8 +- .../contributions/trip-warnings.mcp.ts | 8 +- .../view-contributions.controller.ts | 8 +- server/src/nest/plugins/dependencies.ts | 13 +- server/src/nest/plugins/host/plugin-audit.ts | 158 +- .../nest/plugins/host/plugin-guards.module.ts | 10 +- .../plugins/host/plugin-guards.service.ts | 56 +- .../nest/plugins/host/plugin-host-state.ts | 66 +- server/src/nest/plugins/host/plugin-jobs.ts | 4 +- .../plugins/host/plugin-rpc-host.factory.ts | 10 +- server/src/nest/plugins/host/rpc-host.ts | 6 +- .../nest/plugins/host/rpc-kit/row-shape.ts | 79 + server/src/nest/plugins/host/rpc-kit/types.ts | 2 +- server/src/nest/plugins/host/rpc/db.rpc.ts | 8 +- .../nest/plugins/host/rpc/host-surface.rpc.ts | 97 +- server/src/nest/plugins/host/rpc/meta.rpc.ts | 123 +- server/src/nest/plugins/install/discovery.ts | 119 +- .../plugins/oauth/plugin-oauth.controller.ts | 29 +- .../nest/plugins/oauth/plugin-oauth.module.ts | 16 + .../plugins/oauth/plugin-oauth.service.ts | 120 +- .../plugins/plugin-activity.controller.ts | 10 +- .../nest/plugins/plugin-frame.controller.ts | 8 +- .../nest/plugins/plugin-runtime.service.ts | 564 +- .../plugin-user-settings.controller.ts | 34 +- .../plugins/plugin-user-settings.service.ts | 60 +- .../nest/plugins/plugins-feed.controller.ts | 14 +- .../nest/plugins/plugins-proxy.controller.ts | 11 +- .../nest/plugins/plugins-runtime.module.ts | 50 + server/src/nest/plugins/plugins.controller.ts | 42 +- server/src/nest/plugins/plugins.module.ts | 19 +- server/src/nest/plugins/plugins.service.ts | 274 +- .../nest/plugins/registry/registry.service.ts | 84 +- server/src/nest/plugins/settings-defaults.ts | 14 +- server/src/nest/plugins/signature-status.ts | 15 +- .../plugins/supervisor/plugin-supervisor.ts | 49 +- .../src/nest/plugins/user-erasure-enqueue.ts | 43 + server/src/nest/public-api/api-token.guard.ts | 4 +- .../nest/public-api/public-api.controller.ts | 14 +- .../src/nest/public-api/public-api.module.ts | 21 +- .../src/nest/public-api/public-api.service.ts | 183 +- .../query-helpers/query-helpers.module.ts | 11 +- .../query-helpers/query-helpers.service.ts | 90 +- .../nest/realtime/realtime-gateway.module.ts | 12 +- server/src/nest/realtime/realtime.gateway.ts | 131 +- server/src/nest/realtime/trek-ws.adapter.ts | 88 +- .../receipt-scan/receipt-scan.controller.ts | 12 +- .../nest/receipt-scan/receipt-scan.module.ts | 4 +- .../reservation-import.controller.ts | 2 +- .../reservation-import.mcp.ts | 15 +- .../reservation-import.module.ts | 4 +- .../reservations/reservation-visibility.ts | 34 - .../reservations/reservations-read.module.ts | 18 +- .../reservations-read.repository.ts | 62 - .../reservations/reservations-read.service.ts | 71 + .../reservations/reservations.controller.ts | 46 +- .../src/nest/reservations/reservations.mcp.ts | 146 +- .../nest/reservations/reservations.module.ts | 28 +- .../src/nest/reservations/reservations.rpc.ts | 42 +- .../nest/reservations/reservations.service.ts | 906 +-- .../upcoming-reservations.controller.ts | 4 +- server/src/nest/roadtrip/charging.mcp.ts | 10 +- server/src/nest/roadtrip/charging.service.ts | 11 +- .../roadtrip/day-boundaries.controller.ts | 12 +- .../src/nest/roadtrip/day-boundaries.mcp.ts | 22 +- .../nest/roadtrip/day-boundaries.service.ts | 56 +- server/src/nest/roadtrip/google-route.mcp.ts | 6 +- .../src/nest/roadtrip/google-route.service.ts | 50 +- .../src/nest/roadtrip/roadtrip-hazards.mcp.ts | 8 +- .../nest/roadtrip/roadtrip-hazards.service.ts | 2 + .../nest/roadtrip/roadtrip-plan.service.ts | 175 +- .../nest/roadtrip/roadtrip-planning.mcp.ts | 2 +- .../roadtrip-preferences.controller.ts | 8 +- .../nest/roadtrip/roadtrip-preferences.mcp.ts | 18 +- .../roadtrip/roadtrip-preferences.service.ts | 35 +- .../nest/roadtrip/roadtrip-router.service.ts | 32 +- .../src/nest/roadtrip/roadtrip.controller.ts | 73 +- server/src/nest/roadtrip/roadtrip.mcp.ts | 83 +- server/src/nest/roadtrip/roadtrip.module.ts | 35 + server/src/nest/roadtrip/roadtrip.service.ts | 269 +- .../route-usage/route-usage.controller.ts | 12 +- .../src/nest/route-usage/route-usage.job.ts | 4 +- .../nest/route-usage/route-usage.module.ts | 9 +- .../nest/route-usage/route-usage.service.ts | 97 +- .../nest/scheduling/cron-registrar.service.ts | 66 +- .../school-holidays.controller.ts | 16 +- .../school-holidays/school-holidays.mcp.ts | 22 +- .../school-holidays/school-holidays.module.ts | 18 +- .../school-holidays.service.ts | 125 +- server/src/nest/settings/instance-api-keys.ts | 79 +- .../src/nest/settings/settings.controller.ts | 22 +- server/src/nest/settings/settings.mcp.ts | 12 +- server/src/nest/settings/settings.module.ts | 16 +- server/src/nest/settings/settings.service.ts | 70 +- server/src/nest/share/share.controller.ts | 30 +- server/src/nest/share/share.mcp.ts | 22 +- server/src/nest/share/share.module.ts | 26 +- server/src/nest/share/share.service.ts | 231 +- .../nest/storage/storage-admin.controller.ts | 32 +- .../src/nest/storage/storage-admin.service.ts | 55 +- .../src/nest/storage/storage-jobs.service.ts | 9 +- server/src/nest/storage/storage-paths.ts | 37 +- .../nest/storage/storage-registry.service.ts | 164 +- .../src/nest/storage/storage-stats.service.ts | 26 +- server/src/nest/storage/storage.module.ts | 9 +- server/src/nest/storage/storage.service.ts | 4 +- .../system-notices.controller.ts | 8 +- .../system-notices/system-notices.module.ts | 17 +- .../system-notices/system-notices.service.ts | 137 +- server/src/nest/tags/tags.controller.ts | 22 +- server/src/nest/tags/tags.mcp.ts | 20 +- server/src/nest/tags/tags.module.ts | 13 +- server/src/nest/tags/tags.rpc.ts | 20 +- server/src/nest/tags/tags.service.ts | 94 +- server/src/nest/todo/todo.controller.ts | 46 +- server/src/nest/todo/todo.mcp.ts | 60 +- server/src/nest/todo/todo.module.ts | 15 +- server/src/nest/todo/todo.rpc.ts | 26 +- server/src/nest/todo/todo.service.ts | 166 +- server/src/nest/tokens/token.service.ts | 170 +- server/src/nest/tokens/tokens.module.ts | 19 +- .../nest/transit/google-transit.provider.ts | 27 +- server/src/nest/transit/transit-provider.ts | 22 +- server/src/nest/transit/transit.mcp.ts | 23 +- server/src/nest/transit/transit.module.ts | 14 +- server/src/nest/transit/transit.service.ts | 4 +- .../trip-invite/trip-invite.controller.ts | 40 +- .../src/nest/trip-invite/trip-invite.mcp.ts | 43 +- .../nest/trip-invite/trip-invite.module.ts | 18 +- .../nest/trip-invite/trip-invite.service.ts | 69 +- .../trip-members/trip-members.controller.ts | 52 +- .../nest/trip-members/trip-members.module.ts | 15 +- .../nest/trip-members/trip-members.service.ts | 172 +- .../trip-membership/trip-membership.module.ts | 12 +- .../trip-membership.service.ts | 54 +- .../trip-read-model/trip-read-model.module.ts | 12 +- .../trip-read-model.service.ts | 58 +- server/src/nest/trips/trip-prompts.mcp.ts | 10 +- server/src/nest/trips/trip-select.ts | 39 - server/src/nest/trips/trips.controller.ts | 78 +- server/src/nest/trips/trips.mcp.ts | 116 +- server/src/nest/trips/trips.rpc.ts | 101 +- server/src/nest/trips/trips.service.ts | 760 ++- server/src/nest/unsplash/unsplash.module.ts | 11 +- server/src/nest/unsplash/unsplash.service.ts | 20 +- server/src/nest/vacay/vacay.controller.ts | 134 +- server/src/nest/vacay/vacay.mcp.ts | 130 +- server/src/nest/vacay/vacay.module.ts | 33 +- server/src/nest/vacay/vacay.rpc.ts | 20 +- server/src/nest/vacay/vacay.service.ts | 965 +-- server/src/plugin-mcp-tools.ts | 2 +- server/src/plugin-user-lifecycle.ts | 8 +- server/src/systemNotices/conditions.ts | 12 + server/src/systemNotices/registry.ts | 21 +- server/src/systemNotices/service.ts | 84 +- server/src/types.ts | 9 +- server/tests/build-schema-snapshot.ts | 41 + server/tests/e2e/addons.e2e.test.ts | 55 +- server/tests/e2e/admin.e2e.test.ts | 34 +- server/tests/e2e/airports.e2e.test.ts | 10 +- server/tests/e2e/assignments.e2e.test.ts | 126 +- server/tests/e2e/atlas.e2e.test.ts | 37 +- server/tests/e2e/auth.e2e.test.ts | 37 +- server/tests/e2e/backup.e2e.test.ts | 5 +- server/tests/e2e/booking-import.e2e.test.ts | 28 +- server/tests/e2e/budget.e2e.test.ts | 89 +- .../e2e/calendar-feed-visibility.e2e.test.ts | 38 +- server/tests/e2e/categories.e2e.test.ts | 5 +- server/tests/e2e/collab.e2e.test.ts | 26 +- server/tests/e2e/collections.e2e.test.ts | 39 +- server/tests/e2e/dawarich.e2e.test.ts | 47 +- server/tests/e2e/days.e2e.test.ts | 103 +- server/tests/e2e/doc-sync.e2e.test.ts | 58 +- server/tests/e2e/feeds.e2e.test.ts | 200 +- server/tests/e2e/files.e2e.test.ts | 32 +- server/tests/e2e/google-quota.e2e.test.ts | 42 +- server/tests/e2e/journey.e2e.test.ts | 5 +- server/tests/e2e/mailer.e2e.test.ts | 37 +- server/tests/e2e/maps.e2e.test.ts | 5 +- server/tests/e2e/memories.e2e.test.ts | 5 +- server/tests/e2e/notifications.e2e.test.ts | 5 +- server/tests/e2e/oauth.e2e.test.ts | 5 +- server/tests/e2e/oidc.e2e.test.ts | 7 +- server/tests/e2e/packing.e2e.test.ts | 25 +- server/tests/e2e/place-enrichment.e2e.test.ts | 5 +- server/tests/e2e/place-shadow.e2e.test.ts | 10 +- server/tests/e2e/places.e2e.test.ts | 148 +- server/tests/e2e/plugin-pois.e2e.test.ts | 56 +- server/tests/e2e/plugin-search.e2e.test.ts | 56 +- server/tests/e2e/public-api.e2e.test.ts | 5 +- server/tests/e2e/push.e2e.test.ts | 39 +- server/tests/e2e/receipt-scan.e2e.test.ts | 37 +- server/tests/e2e/reservations.e2e.test.ts | 78 +- server/tests/e2e/roadtrip.e2e.test.ts | 129 +- server/tests/e2e/school-holidays.e2e.test.ts | 170 +- server/tests/e2e/settings.e2e.test.ts | 5 +- server/tests/e2e/share.e2e.test.ts | 44 +- server/tests/e2e/storage-admin.e2e.test.ts | 5 +- server/tests/e2e/system-notices.e2e.test.ts | 92 +- server/tests/e2e/tags.e2e.test.ts | 5 +- server/tests/e2e/todo.e2e.test.ts | 24 +- server/tests/e2e/transit-provider.e2e.test.ts | 5 +- server/tests/e2e/transit.e2e.test.ts | 5 +- server/tests/e2e/trip-invite.e2e.test.ts | 26 +- server/tests/e2e/trips.e2e.test.ts | 176 +- server/tests/e2e/vacay.e2e.test.ts | 5 +- server/tests/e2e/weather.e2e.test.ts | 11 +- server/tests/fixtures/legacy/legacy-v066.sql | 574 ++ server/tests/fixtures/legacy/legacy-v082.sql | 662 ++ server/tests/fixtures/legacy/legacy-v205.sql | 1267 ++++ server/tests/fixtures/legacy/legacy-v242.sql | 1536 +++++ server/tests/global-setup.ts | 39 + .../tests/helpers/accommodations-service.ts | 68 +- server/tests/helpers/atlas-repos.ts | 57 + server/tests/helpers/budget-repos.ts | 72 + server/tests/helpers/collab-repos.ts | 55 + server/tests/helpers/dawarich-repos.ts | 40 + server/tests/helpers/db-mock.ts | 141 + server/tests/helpers/doc-sync-repos.ts | 105 + server/tests/helpers/factories.ts | 9 +- server/tests/helpers/files-repos.ts | 40 + server/tests/helpers/google-quota.ts | 2 +- server/tests/helpers/journey-repos.ts | 62 + server/tests/helpers/journey-share-repos.ts | 26 + server/tests/helpers/legacy-fixture.ts | 78 + server/tests/helpers/legacy-upgrade-suite.ts | 154 + server/tests/helpers/mcp-harness.ts | 2 +- server/tests/helpers/mcp-test-controllers.ts | 397 +- server/tests/helpers/migration-step.ts | 66 + server/tests/helpers/notifications-repos.ts | 33 + server/tests/helpers/notifications.ts | 70 +- server/tests/helpers/packing-repos.ts | 60 + server/tests/helpers/plugin-host.ts | 332 +- server/tests/helpers/rpc-host-deps.ts | 12 +- server/tests/helpers/school-holidays-repos.ts | 46 + server/tests/helpers/share-repos.ts | 86 + server/tests/helpers/test-addons.ts | 44 + server/tests/helpers/test-db.ts | 279 +- server/tests/helpers/test-orm.ts | 117 + server/tests/helpers/test-uow.ts | 687 +++ server/tests/helpers/todo-repos.ts | 28 + server/tests/helpers/vacay-repos.ts | 72 + server/tests/integration/admin.test.ts | 52 +- .../airtrail-sync-request-context.test.ts | 171 + server/tests/integration/api-docs.test.ts | 26 +- server/tests/integration/app-config.test.ts | 26 +- server/tests/integration/assignments.test.ts | 56 +- .../async-closure-truthiness-guards.test.ts | 185 + .../atlas-geocode-request-context.test.ts | 108 + server/tests/integration/atlas.test.ts | 32 +- server/tests/integration/auth.test.ts | 113 +- server/tests/integration/backup.test.ts | 32 +- .../boot-sweeps-request-context.test.ts | 171 + server/tests/integration/bootstrap.test.ts | 44 +- server/tests/integration/budget.test.ts | 32 +- server/tests/integration/categories.test.ts | 46 +- server/tests/integration/collab.test.ts | 32 +- server/tests/integration/collections.test.ts | 26 +- server/tests/integration/dayNotes.test.ts | 32 +- server/tests/integration/dayReorder.test.ts | 79 +- server/tests/integration/days.test.ts | 294 +- .../demo-reset-connection-swap.spike.test.ts | 170 + .../demo-seed-request-context.test.ts | 122 + .../doc-sync-webhook-request-context.test.ts | 134 + .../integration/feeds-request-context.test.ts | 218 + server/tests/integration/files.test.ts | 32 +- server/tests/integration/help.test.ts | 15 +- server/tests/integration/immich.test.ts | 37 +- .../integration/journey-place-order.test.ts | 135 + server/tests/integration/journey.test.ts | 37 +- .../integration/leg-mode-incoming.test.ts | 94 +- .../integration/legacy-upgrade-v066.test.ts | 55 + .../integration/legacy-upgrade-v082.test.ts | 34 + .../integration/legacy-upgrade-v205.test.ts | 34 + .../integration/legacy-upgrade-v242.test.ts | 34 + server/tests/integration/maps.test.ts | 32 +- server/tests/integration/mcp.test.ts | 127 +- .../tests/integration/memories-immich.test.ts | 62 +- .../integration/memories-synology.test.ts | 75 +- .../integration/memories-unified.test.ts | 32 +- server/tests/integration/misc.test.ts | 32 +- .../tests/integration/notifications.test.ts | 32 +- .../tests/integration/oauth-authorize.test.ts | 41 +- server/tests/integration/oauth.test.ts | 151 +- .../integration/oidc-local-provider.test.ts | 28 +- server/tests/integration/oidc.test.ts | 29 +- .../orm-request-context-seams.test.ts | 210 + server/tests/integration/packing.test.ts | 102 +- server/tests/integration/passkey.test.ts | 156 + server/tests/integration/places.test.ts | 351 +- .../plugins/boot-registry-order.test.ts | 213 +- .../integration/plugins/dev-link.test.ts | 37 +- .../plugin-runtime.task2-conversion.test.ts | 454 ++ .../plugins/plugin-runtime.test.ts | 95 +- .../plugins/plugin-scheduler-sweep.test.ts | 368 ++ .../integration/plugins/registry.test.ts | 64 +- .../integration/plugins/supervisor.test.ts | 19 +- server/tests/integration/profile.test.ts | 90 +- server/tests/integration/reservations.test.ts | 217 +- .../tests/integration/scheduler-gate.test.ts | 26 +- server/tests/integration/security.test.ts | 32 +- server/tests/integration/settings.test.ts | 27 +- server/tests/integration/share.test.ts | 46 +- ...ge-health-notifier-request-context.test.ts | 80 + .../storage-restore-reload.test.ts | 31 +- .../tests/integration/systemNotices.test.ts | 68 +- server/tests/integration/tags.test.ts | 46 +- server/tests/integration/todo.test.ts | 27 +- .../trip-access-primitives.test.ts | 392 ++ server/tests/integration/trips.test.ts | 37 +- .../uploads-photo-request-context.test.ts | 185 + .../tests/integration/uploads-static.test.ts | 24 +- server/tests/integration/vacay.test.ts | 32 +- server/tests/setup.ts | 13 + .../unit/app-config/nest-log-levels.test.ts | 30 + server/tests/unit/auto-backup.test.ts | 68 +- server/tests/unit/budget/costs.rpc.test.ts | 11 +- server/tests/unit/days/day-notes.rpc.test.ts | 9 +- .../unit/days/days-itinerary.rpc.test.ts | 9 +- .../unit/db/atlas-region-crosswalk.test.ts | 161 +- .../tests/unit/db/booked-night-reseat.test.ts | 111 +- .../db/budget-ticket-json-migration.test.ts | 165 +- server/tests/unit/db/connection.test.ts | 34 +- .../unit/db/dialect/sql-functions.test.ts | 1477 +++++ server/tests/unit/db/entities-index.test.ts | 109 + .../entities/primary-relations.spike.test.ts | 174 + .../tests/unit/db/entity-class-fields.test.ts | 187 + .../unit/db/entity-schema-parity.test.ts | 627 ++ .../tests/unit/db/generate-entities.test.ts | 1584 +++++ .../immich-provider-fields-migration.test.ts | 185 +- .../unit/db/instance-api-key-backfill.test.ts | 213 +- server/tests/unit/db/legacy-baseline.test.ts | 224 + .../tests/unit/db/migration-hygiene.test.ts | 200 - .../db/migration-version-atomicity.test.ts | 55 - .../unit/db/nul-safe-sqlite-platform.test.ts | 194 + .../db/place-regions-follow-place.test.ts | 410 +- .../db/repositories/Addons.repository.test.ts | 149 + .../AppSettings.repository.test.ts | 199 + .../AssignmentParticipants.repository.test.ts | 323 + .../repositories/AuditLog.repository.test.ts | 137 + .../BucketList.repository.test.ts | 123 + .../BudgetItems.repository.test.ts | 90 + .../Categories.repository.test.ts | 246 + .../CollabMessages.repository.test.ts | 73 + .../DawarichConnections.repository.test.ts | 250 + ...awarichVisitSuggestions.repository.test.ts | 421 ++ .../DayAccommodations.repository.test.ts | 154 + .../DayAssignments.repository.test.ts | 999 +++ .../repositories/DayNotes.repository.test.ts | 278 + .../db/repositories/Days.repository.test.ts | 388 ++ .../db/repositories/DemoRepository.test.ts | 154 + .../DocumentProviders.repository.test.ts | 79 + .../DocumentSyncItems.repository.test.ts | 245 + .../GooglePlacePhotoMeta.repository.test.ts | 138 + .../HiddenRegions.repository.test.ts | 44 + .../IdempotencyKeys.repository.test.ts | 103 + .../InviteTokens.repository.test.ts | 241 + .../MaintenanceRepository.test.ts | 88 + .../repositories/McpTokens.repository.test.ts | 532 ++ ...ationChannelPreferences.repository.test.ts | 123 + .../Notifications.repository.test.ts | 258 + .../OauthClients.repository.test.ts | 284 + .../OauthConsents.repository.test.ts | 123 + .../OauthTokens.repository.test.ts | 532 ++ .../PackingItems.repository.test.ts | 82 + .../PasswordResetTokens.repository.test.ts | 228 + .../PhotoProviderFields.repository.test.ts | 146 + .../PhotoProviders.repository.test.ts | 132 + .../PlaceDetailsCache.repository.test.ts | 112 + .../PlaceRatings.repository.test.ts | 184 + .../PlaceRegions.repository.test.ts | 179 + .../PlaceShadowPicks.repository.test.ts | 179 + .../db/repositories/Places.repository.test.ts | 1453 +++++ .../PluginOauthTokens.repository.test.ts | 115 + ...ReservationDayPositions.repository.test.ts | 62 + .../ReservationEndpoints.repository.test.ts | 97 + .../ReservationTravelers.repository.test.ts | 69 + .../Reservations.repository.test.ts | 804 +++ .../RoadtripDayBoundaries.repository.test.ts | 105 + .../RoadtripDayTracks.repository.test.ts | 142 + .../RoadtripPreferences.repository.test.ts | 66 + .../RoadtripVias.repository.test.ts | 173 + .../repositories/Settings.repository.test.ts | 155 + .../ShareTokens.repository.test.ts | 279 + .../db/repositories/Tags.repository.test.ts | 497 ++ .../repositories/TodoItems.repository.test.ts | 87 + .../TripInviteTokens.repository.test.ts | 114 + .../TripMembers.repository.test.ts | 416 ++ .../db/repositories/Trips.repository.test.ts | 1042 ++++ .../UserNoticeDismissals.repository.test.ts | 114 + .../db/repositories/Users.repository.test.ts | 1203 ++++ .../VacayCompanyHolidays.repository.test.ts | 50 + .../VacayHolidayCalendars.repository.test.ts | 81 + .../VacayPlanMembers.repository.test.ts | 104 + .../VacayPlans.repository.test.ts | 63 + .../VacayShares.repository.test.ts | 121 + .../VacayUserSettings.repository.test.ts | 47 + .../VacayUserYears.repository.test.ts | 58 + .../VacayYears.repository.test.ts | 50 + .../VisitedCountries.repository.test.ts | 69 + .../VisitedRegions.repository.test.ts | 84 + .../WebauthnChallenges.repository.test.ts | 135 + .../WebauthnCredentials.repository.test.ts | 314 + .../atlas-travowns-consumers.parity.test.ts | 155 + .../_shared/collection-role.parity.test.ts | 131 + .../repositories/_shared/owned-lookup.test.ts | 116 + .../_shared/packing-visibility.parity.test.ts | 190 + .../reservation-travelers-owns.parity.test.ts | 183 + .../_shared/reservation-visibility.test.ts | 410 ++ .../addons-config-encryption.test.ts | 75 + .../atlas-branch-coverage.test.ts | 68 + .../bucket-list-duplicate.parity.test.ts | 174 + .../identity-map-writeback.test.ts | 100 + .../db/repositories/trek-repository.test.ts | 312 + .../vacay-branch-coverage.test.ts | 148 + ...reservation-ingest-state-migration.test.ts | 75 +- .../unit/db/restore-migrates-forward.test.ts | 94 + .../db/roadtrip-preferences-migration.test.ts | 69 +- .../unit/db/schema-version-seeder.test.ts | 46 + server/tests/unit/db/seeds-admin.test.ts | 38 +- server/tests/unit/db/test-orm.test.ts | 39 + .../tests/unit/db/transaction-yield.test.ts | 198 + server/tests/unit/db/trip-access.test.ts | 81 +- .../db/trip-days-backfill-migration.test.ts | 226 +- .../db/trip-files-prefix-migration.test.ts | 55 +- .../unit/db/types/db-timestamp.type.test.ts | 48 + .../tests/unit/demo/demo-reset-path.test.ts | 93 +- server/tests/unit/demo/demo-seed.test.ts | 85 +- .../eslint-rules/no-promise-as-value.test.ts | 180 + server/tests/unit/files/files.rpc.test.ts | 62 +- server/tests/unit/helpers/test-uow.test.ts | 42 + server/tests/unit/idempotency-cleanup.test.ts | 80 +- .../tests/unit/mcp/tools-addon-gating.test.ts | 31 +- server/tests/unit/mcp/tools-airtrail.test.ts | 48 +- ...ols-assignments-reservations-extra.test.ts | 31 +- .../tests/unit/mcp/tools-assignments.test.ts | 31 +- .../unit/mcp/tools-atlas-expanded.test.ts | 31 +- server/tests/unit/mcp/tools-atlas.test.ts | 31 +- .../unit/mcp/tools-budget-advanced.test.ts | 42 +- server/tests/unit/mcp/tools-budget.test.ts | 31 +- .../tests/unit/mcp/tools-categories.test.ts | 47 +- .../unit/mcp/tools-collab-polls-chat.test.ts | 31 +- .../tests/unit/mcp/tools-collections.test.ts | 30 +- .../mcp/tools-days-accommodations.test.ts | 42 +- server/tests/unit/mcp/tools-days.test.ts | 31 +- .../tests/unit/mcp/tools-feeds-invite.test.ts | 31 +- server/tests/unit/mcp/tools-files.test.ts | 31 +- .../tests/unit/mcp/tools-help-addons.test.ts | 43 +- server/tests/unit/mcp/tools-journey.test.ts | 31 +- .../unit/mcp/tools-leg-transport-mode.test.ts | 31 +- server/tests/unit/mcp/tools-memories.test.ts | 31 +- server/tests/unit/mcp/tools-notes.test.ts | 31 +- .../unit/mcp/tools-notifications.test.ts | 31 +- .../unit/mcp/tools-packing-advanced.test.ts | 31 +- server/tests/unit/mcp/tools-packing.test.ts | 31 +- server/tests/unit/mcp/tools-places.test.ts | 36 +- .../tests/unit/mcp/tools-plugin-pois.test.ts | 30 +- .../unit/mcp/tools-plugin-search.test.ts | 33 +- server/tests/unit/mcp/tools-prompts.test.ts | 133 +- .../tests/unit/mcp/tools-reservations.test.ts | 31 +- server/tests/unit/mcp/tools-roadtrip.test.ts | 44 +- server/tests/unit/mcp/tools-settings.test.ts | 31 +- server/tests/unit/mcp/tools-share.test.ts | 31 +- .../unit/mcp/tools-tags-maps-weather.test.ts | 41 +- server/tests/unit/mcp/tools-todos.test.ts | 31 +- server/tests/unit/mcp/tools-transit.test.ts | 50 +- .../tests/unit/mcp/tools-transports.test.ts | 31 +- .../tests/unit/mcp/tools-trip-members.test.ts | 31 +- .../unit/mcp/tools-trip-warnings.test.ts | 33 +- server/tests/unit/mcp/tools-trips.test.ts | 31 +- server/tests/unit/mcp/tools-vacay.test.ts | 33 +- server/tests/unit/nest-mcp/attach.test.ts | 8 +- .../nest-mcp/create-test-registry.test.ts | 4 +- server/tests/unit/nest-mcp/harness.ts | 2 +- .../nest/accommodations.controller.test.ts | 64 +- .../unit/nest/accommodations.service.test.ts | 552 +- server/tests/unit/nest/addon-guard.test.ts | 20 +- .../tests/unit/nest/addons.controller.test.ts | 4 +- server/tests/unit/nest/addons.service.test.ts | 744 +-- ...n-default-user-settings.controller.test.ts | 31 +- server/tests/unit/nest/admin-jobs.test.ts | 4 +- ...otification-preferences.controller.test.ts | 18 +- ...admin-packing-templates.controller.test.ts | 70 +- .../tests/unit/nest/admin.controller.test.ts | 130 +- server/tests/unit/nest/admin.service.test.ts | 486 +- .../nest/admin.version-notification.test.ts | 101 +- .../nest/airports/airports.service.test.ts | 106 + .../tests/unit/nest/airtrail-sync.job.test.ts | 136 + .../tests/unit/nest/airtrail.service.test.ts | 84 + .../unit/nest/assignments.controller.test.ts | 116 +- .../unit/nest/assignments.service.test.ts | 385 +- .../tests/unit/nest/atlas.controller.test.ts | 54 +- server/tests/unit/nest/atlas.service.test.ts | 420 +- server/tests/unit/nest/audit.service.test.ts | 134 +- server/tests/unit/nest/auth-guard.test.ts | 98 +- .../tests/unit/nest/auth.controller.test.ts | 182 +- server/tests/unit/nest/auth.service.test.ts | 601 +- .../tests/unit/nest/auth/jwt-verify.test.ts | 91 +- .../tests/unit/nest/backup.controller.test.ts | 22 +- server/tests/unit/nest/backup.impl.test.ts | 73 +- .../booking-import.service.test.ts | 13 +- .../import-jobs.service.test.ts | 46 +- server/tests/unit/nest/boot-restore.test.ts | 14 +- .../tests/unit/nest/budget.controller.test.ts | 191 +- .../unit/nest/budget.service.calc.test.ts | 490 +- .../tests/unit/nest/budget.service.db.test.ts | 865 ++- server/tests/unit/nest/budget.service.test.ts | 217 +- .../tests/unit/nest/cache-sweep-jobs.test.ts | 48 +- .../tests/unit/nest/calendar.service.test.ts | 395 +- .../unit/nest/categories.controller.test.ts | 28 +- .../unit/nest/categories.service.test.ts | 165 +- .../tests/unit/nest/channel-override.test.ts | 76 +- .../tests/unit/nest/channel-registry.test.ts | 94 +- server/tests/unit/nest/charging.test.ts | 26 +- .../tests/unit/nest/collab.controller.test.ts | 140 +- server/tests/unit/nest/collab.service.test.ts | 523 +- .../unit/nest/collections.controller.test.ts | 146 +- .../unit/nest/collections.service.test.ts | 1188 ++-- server/tests/unit/nest/common/row-id.test.ts | 65 + .../tests/unit/nest/common/rowShape.test.ts | 32 +- .../unit/nest/cron-registrar.service.test.ts | 119 + .../nest/database-service.delegation.test.ts | 39 - .../tests/unit/nest/database-service.test.ts | 74 - .../nest/database/request-context.test.ts | 40 + .../nest/database/unit-of-work.d5.test.ts | 53 + .../unit/nest/database/unit-of-work.test.ts | 95 + .../nest/dawarich-suggestions.service.test.ts | 376 +- .../tests/unit/nest/dawarich-sync.job.test.ts | 62 +- .../unit/nest/dawarich-sync.service.test.ts | 111 +- .../unit/nest/dawarich-tracks.service.test.ts | 12 +- .../unit/nest/dawarich.controller.test.ts | 56 +- server/tests/unit/nest/dawarich.mcp.test.ts | 102 +- .../tests/unit/nest/dawarich.service.test.ts | 240 +- server/tests/unit/nest/day-boundaries.test.ts | 170 +- .../tests/unit/nest/day-notes.service.test.ts | 183 +- .../unit/nest/day-removal.service.test.ts | 206 +- .../tests/unit/nest/days.controller.test.ts | 140 +- server/tests/unit/nest/days.service.test.ts | 343 +- .../nest/demo-service-survivor-sites.test.ts | 56 + server/tests/unit/nest/demo.service.test.ts | 57 + .../unit/nest/discovery.controller.test.ts | 56 +- .../doc-sync/doc-sync-config.service.test.ts | 347 +- .../doc-sync-webhook.controller.test.ts | 249 +- .../nest/doc-sync/doc-sync.controller.test.ts | 170 +- .../unit/nest/doc-sync/doc-sync.job.test.ts | 136 +- .../unit/nest/doc-sync/doc-sync.mcp.test.ts | 30 +- .../nest/doc-sync/doc-sync.service.test.ts | 542 +- .../doc-sync/synology-drive.provider.test.ts | 2 +- .../unit/nest/features.controller.test.ts | 24 +- server/tests/unit/nest/feeds.service.test.ts | 288 +- .../tests/unit/nest/files.controller.test.ts | 64 +- server/tests/unit/nest/files.service.test.ts | 583 +- .../unit/nest/files/upload-filters.test.ts | 149 + .../unit/nest/google-quota.service.test.ts | 109 +- server/tests/unit/nest/google-route.test.ts | 69 +- .../unit/nest/google-transit.provider.test.ts | 125 +- .../unit/nest/idempotency.interceptor.test.ts | 191 +- server/tests/unit/nest/immich.service.test.ts | 121 +- .../unit/nest/journey-book.service.test.ts | 287 +- .../unit/nest/journey-domain.service.test.ts | 1988 ++++-- .../journey-photo-capture.service.test.ts | 89 +- .../unit/nest/journey-share.service.test.ts | 625 +- .../unit/nest/journey-stats.service.test.ts | 173 +- .../unit/nest/journey.controller.test.ts | 238 +- .../llm-parse/llm-config.resolver.test.ts | 104 +- .../nest/llm-parse/llm-parse.service.test.ts | 6 +- server/tests/unit/nest/mailer.service.test.ts | 31 +- server/tests/unit/nest/maps.amap.test.ts | 82 +- server/tests/unit/nest/maps.area.test.ts | 12 +- .../tests/unit/nest/maps.autocomplete.test.ts | 25 +- .../tests/unit/nest/maps.controller.test.ts | 30 +- .../unit/nest/maps.curated-images.test.ts | 4 +- server/tests/unit/nest/maps.geocode.test.ts | 12 +- .../tests/unit/nest/maps.gers-details.test.ts | 15 +- .../tests/unit/nest/maps.google-quota.test.ts | 45 +- server/tests/unit/nest/maps.identity.test.ts | 4 +- server/tests/unit/nest/maps.nearby.test.ts | 13 +- server/tests/unit/nest/maps.pois.test.ts | 12 +- .../tests/unit/nest/maps.search-merge.test.ts | 18 +- server/tests/unit/nest/maps.service.test.ts | 160 +- server/tests/unit/nest/mcp-addon-gate.test.ts | 16 +- .../unit/nest/mcp-shared.service.test.ts | 63 +- .../unit/nest/mcp-transport.service.test.ts | 34 +- .../unit/nest/memories-access.service.test.ts | 388 +- .../unit/nest/memories.controller.test.ts | 48 +- .../tests/unit/nest/memories.service.test.ts | 22 +- .../nest/memories/journey-thumbs.job.test.ts | 82 + .../memories/trek-photo-cache.job.test.ts | 67 + .../tests/unit/nest/mfa-policy.guard.test.ts | 105 +- .../notification-preferences.service.test.ts | 246 +- .../unit/nest/notification-transports.test.ts | 119 +- .../nest/notifications.controller.test.ts | 36 +- .../nest/notifications.inapp-prefs.test.ts | 61 +- .../unit/nest/notifications.service.test.ts | 43 +- .../push-subscriptions.service.test.ts | 109 +- .../notifications/push.controller.test.ts | 48 +- .../storage-health-notifier.service.test.ts | 23 +- .../notifications/vapid-keys.service.test.ts | 215 +- .../notifications/web-push-dispatch.test.ts | 109 +- .../notifications/web-push.service.test.ts | 152 +- .../tests/unit/nest/oauth.controller.test.ts | 234 +- server/tests/unit/nest/oauth.service.test.ts | 680 +- server/tests/unit/nest/oidc.service.test.ts | 383 +- .../unit/nest/packing.controller.test.ts | 286 +- .../tests/unit/nest/packing.service.test.ts | 886 ++- .../tests/unit/nest/passkey.service.test.ts | 162 +- .../unit/nest/permissions.service.test.ts | 247 +- .../nest/photo-capture-backfill.exif.test.ts | 4 +- .../photo-capture-backfill.service.test.ts | 12 +- .../unit/nest/photo-resolver.service.test.ts | 54 +- .../unit/nest/place-enrichment.brand.test.ts | 46 +- .../nest/place-enrichment.description.test.ts | 68 +- .../nest/place-enrichment.service.test.ts | 115 +- .../place-photo-cache.job.test.ts | 61 + .../place-photo-cache.service.test.ts | 129 +- .../unit/nest/place-regions-repair.test.ts | 100 +- .../unit/nest/place-shadow.controller.test.ts | 30 +- .../tests/unit/nest/place-shadow.job.test.ts | 32 +- .../unit/nest/place-shadow.service.test.ts | 204 +- .../tests/unit/nest/places.controller.test.ts | 109 +- .../tests/unit/nest/places.directions.test.ts | 88 +- server/tests/unit/nest/places.service.test.ts | 905 ++- .../unit/nest/platform.controller.test.ts | 132 +- .../tests/unit/nest/public-api-scopes.test.ts | 249 +- .../unit/nest/public-api.controller.test.ts | 60 +- .../tests/unit/nest/public-api.guard.test.ts | 36 +- .../unit/nest/public-stats.controller.test.ts | 42 +- .../unit/nest/query-helpers.service.test.ts | 183 + .../tests/unit/nest/realtime.gateway.test.ts | 228 +- .../unit/nest/receipt-scan.controller.test.ts | 8 +- .../nest/registration-invites.service.test.ts | 68 +- .../unit/nest/reminder-jobs.service.test.ts | 75 +- .../unit/nest/reservations.controller.test.ts | 68 +- .../unit/nest/reservations.service.test.ts | 617 +- .../unit/nest/roadtrip-plan.service.test.ts | 103 +- .../tests/unit/nest/roadtrip-planning.test.ts | 220 +- .../unit/nest/roadtrip-read-tools.test.ts | 38 +- .../tests/unit/nest/roadtrip-router.test.ts | 12 +- .../unit/nest/roadtrip.controller.test.ts | 88 +- .../tests/unit/nest/roadtrip.service.test.ts | 405 +- .../tests/unit/nest/route-inventory.test.ts | 13 +- .../unit/nest/route-usage.service.test.ts | 79 +- .../unit/nest/school-holidays.service.test.ts | 192 + .../nest/session-renewal.interceptor.test.ts | 4 +- .../unit/nest/settings.controller.test.ts | 22 +- .../tests/unit/nest/settings.service.test.ts | 256 +- .../nest/settings/instance-api-keys.test.ts | 121 +- .../tests/unit/nest/share.controller.test.ts | 44 +- server/tests/unit/nest/share.service.test.ts | 363 +- .../storage/storage-admin.controller.test.ts | 62 +- .../storage/storage-admin.service.test.ts | 234 +- .../nest/storage/storage-jobs.service.test.ts | 181 +- .../storage/storage-registry.service.test.ts | 410 +- .../storage/storage-stats.service.test.ts | 56 +- .../storage/storage-usage-scan.job.test.ts | 10 +- .../tests/unit/nest/synology.service.test.ts | 57 +- .../system-notices.branch-coverage.test.ts | 169 + .../nest/system-notices.controller.test.ts | 38 +- .../unit/nest/system-notices.service.test.ts | 296 +- .../tests/unit/nest/tags.controller.test.ts | 32 +- server/tests/unit/nest/tags.service.test.ts | 171 +- .../tests/unit/nest/thumbnail.service.test.ts | 30 +- .../unit/nest/to-client-user.parity.test.ts | 87 + .../tests/unit/nest/todo.controller.test.ts | 68 +- server/tests/unit/nest/todo.service.test.ts | 245 +- server/tests/unit/nest/token.service.test.ts | 225 +- .../tests/unit/nest/transit.service.test.ts | 8 +- .../unit/nest/travel-stats.service.test.ts | 123 +- .../nest/trek-photo-cache.service.test.ts | 34 +- .../trek-photo-registration.service.test.ts | 272 + .../unit/nest/trek-photos.repository.test.ts | 108 - .../tests/unit/nest/trek-ws-adapter.test.ts | 282 +- .../tests/unit/nest/trip-access.guard.test.ts | 73 +- .../unit/nest/trip-invite.service.test.ts | 91 +- .../unit/nest/trip-members.controller.test.ts | 92 +- .../unit/nest/trip-members.service.test.ts | 301 +- .../trip-membership.service.test.ts | 121 +- .../tests/unit/nest/trip-owner.guard.test.ts | 76 +- .../unit/nest/trip-read-model.service.test.ts | 261 +- .../tests/unit/nest/trips.controller.test.ts | 151 +- server/tests/unit/nest/trips.service.test.ts | 967 ++- .../nest/unified-memories.service.test.ts | 337 +- .../nest/unsplash/unsplash.service.test.ts | 81 +- .../unit/nest/user-cleanup.service.test.ts | 240 +- .../unit/nest/user-profile.service.test.ts | 163 +- .../tests/unit/nest/vacay.controller.test.ts | 82 +- server/tests/unit/nest/vacay.service.test.ts | 1214 ++-- .../unit/nest/webauthn-config.service.test.ts | 71 +- server/tests/unit/nest/wiring.test.ts | 17 +- server/tests/unit/packing/packing.rpc.test.ts | 30 +- server/tests/unit/places/places.rpc.test.ts | 9 +- .../plugins/addon-domains-errors.rpc.test.ts | 8 +- .../unit/plugins/addon-gates.rpc.test.ts | 11 +- .../plugins/day-schedule.controller.test.ts | 14 +- .../unit/plugins/day-tints.controller.test.ts | 14 +- .../tests/unit/plugins/dependencies.test.ts | 6 +- server/tests/unit/plugins/discovery.test.ts | 113 +- server/tests/unit/plugins/host-rpc.test.ts | 267 +- .../journal-entry-rows.controller.test.ts | 15 +- .../unit/plugins/journal-photo.rpc.test.ts | 30 +- .../plugins/map-layers.controller.test.ts | 10 +- .../plugins/map-markers.controller.test.ts | 10 +- .../tests/unit/plugins/mcp-tool-sink.test.ts | 12 +- .../unit/plugins/operator-egress.test.ts | 71 +- .../plugins/pdf-sections.controller.test.ts | 10 +- .../plugins/place-details.controller.test.ts | 14 +- .../tests/unit/plugins/plugin-audit.test.ts | 204 +- .../tests/unit/plugins/plugin-frame.test.ts | 60 +- .../tests/unit/plugins/plugin-guards.test.ts | 196 +- .../unit/plugins/plugin-host-state.test.ts | 69 + .../plugins/plugin-instance-settings.test.ts | 194 +- .../unit/plugins/plugin-mcp-tools.test.ts | 69 +- .../plugins/plugin-oauth.controller.test.ts | 32 +- .../unit/plugins/plugin-oauth.service.test.ts | 130 +- server/tests/unit/plugins/plugin-pois.test.ts | 50 +- .../plugin-repository-sql-parity.test.ts | 212 + .../plugins/plugin-retrust.controller.test.ts | 6 +- .../plugins/plugin-route-normalize.test.ts | 51 + .../plugins/plugin-routes.controller.test.ts | 20 +- .../plugin-runtime.boot-no-orm.test.ts | 125 + .../plugin-user-settings.controller.test.ts | 36 +- .../unit/plugins/plugin-user-settings.test.ts | 136 +- .../plugins/plugins-feed.controller.test.ts | 44 +- .../tests/unit/plugins/plugins-proxy.test.ts | 41 +- .../unit/plugins/plugins-service.test.ts | 205 +- .../unit/plugins/provider-hook-grant.test.ts | 3 +- .../unit/plugins/request-context.test.ts | 160 + .../tests/unit/plugins/rpc-fallbacks.test.ts | 40 +- .../unit/plugins/rpc-host-inv-strip.test.ts | 18 +- server/tests/unit/plugins/rpc-host.test.ts | 2 +- .../unit/plugins/rpc-kit/row-shape.test.ts | 121 + .../unit/plugins/settings-isolation.test.ts | 113 +- .../unit/plugins/supervisor-lifecycle.test.ts | 15 +- ...trip-card-contributions.controller.test.ts | 6 +- .../plugins/trip-warnings.controller.test.ts | 10 +- .../view-contributions.controller.test.ts | 10 +- .../unit/reservations/bookings.rpc.test.ts | 9 +- .../tests/unit/scripts/db-call-graph.test.ts | 120 + .../unit/services/airtrailImport.test.ts | 31 +- .../services/airtrailTestConnection.test.ts | 26 +- .../unit/services/airtrailWriteGate.test.ts | 95 +- .../unit/services/airtrailWriteback.test.ts | 94 +- .../unit/services/conflictUpdate.test.ts | 151 +- server/tests/unit/services/migration.test.ts | 332 +- .../unit/services/trekPhotoMedia.test.ts | 56 +- .../unit/services/trimUserWhitespace.test.ts | 237 +- server/tests/unit/shutdown.test.ts | 9 +- .../unit/systemNotices/conditions.test.ts | 5 + server/tests/unit/tags/tags.rpc.test.ts | 46 +- server/tests/unit/todo/todo.rpc.test.ts | 13 +- server/tests/unit/trips/trips.rpc.test.ts | 106 +- server/tests/websocket/connection.test.ts | 122 +- server/tests/websocket/wire-envelope.test.ts | 27 +- server/tsconfig.scripts.json | 8 + server/vitest.config.ts | 35 +- shared/package.json | 18 +- sonar-project.properties | 12 +- wiki/Real-Time-Collaboration.md | 2 +- wiki/Troubleshooting.md | 2 +- 1613 files changed, 137631 insertions(+), 43502 deletions(-) create mode 100644 server/eslint-rules/fixtures/file.ts create mode 100644 server/eslint-rules/fixtures/strict.ts create mode 100644 server/eslint-rules/fixtures/tsconfig.json create mode 100644 server/eslint-rules/fixtures/tsconfig.strict.json create mode 100644 server/eslint-rules/no-promise-as-value.mjs create mode 100644 server/scripts/__fixtures__/sync-db-sample.ts create mode 100644 server/scripts/__fixtures__/unawaited-sample.ts create mode 100644 server/scripts/db-call-graph.mjs create mode 100644 server/scripts/generate-entities.ts create mode 100644 server/src/app-config/nest-log-levels.ts create mode 100644 server/src/db/db-path.ts create mode 100644 server/src/db/dialect/sql-functions.ts create mode 100644 server/src/db/entities/Addons.entity.ts create mode 100644 server/src/db/entities/AppSettings.entity.ts create mode 100644 server/src/db/entities/AssignmentParticipants.entity.ts create mode 100644 server/src/db/entities/AuditLog.entity.ts create mode 100644 server/src/db/entities/BucketList.entity.ts create mode 100644 server/src/db/entities/BudgetCategoryOrder.entity.ts create mode 100644 server/src/db/entities/BudgetItemMembers.entity.ts create mode 100644 server/src/db/entities/BudgetItemPayers.entity.ts create mode 100644 server/src/db/entities/BudgetItems.entity.ts create mode 100644 server/src/db/entities/BudgetSettlements.entity.ts create mode 100644 server/src/db/entities/Categories.entity.ts create mode 100644 server/src/db/entities/CollabLinks.entity.ts create mode 100644 server/src/db/entities/CollabMessageReactions.entity.ts create mode 100644 server/src/db/entities/CollabMessages.entity.ts create mode 100644 server/src/db/entities/CollabNotes.entity.ts create mode 100644 server/src/db/entities/CollabPollVotes.entity.ts create mode 100644 server/src/db/entities/CollabPolls.entity.ts create mode 100644 server/src/db/entities/CollectionLabels.entity.ts create mode 100644 server/src/db/entities/CollectionMembers.entity.ts create mode 100644 server/src/db/entities/CollectionPlaceRatings.entity.ts create mode 100644 server/src/db/entities/CollectionPlaces.entity.ts create mode 100644 server/src/db/entities/Collections.entity.ts create mode 100644 server/src/db/entities/DawarichConnections.entity.ts create mode 100644 server/src/db/entities/DawarichVisitSuggestions.entity.ts create mode 100644 server/src/db/entities/DayAccommodations.entity.ts create mode 100644 server/src/db/entities/DayAssignments.entity.ts create mode 100644 server/src/db/entities/DayNotes.entity.ts create mode 100644 server/src/db/entities/Days.entity.ts create mode 100644 server/src/db/entities/DocumentConnections.entity.ts create mode 100644 server/src/db/entities/DocumentProviderFields.entity.ts create mode 100644 server/src/db/entities/DocumentProviders.entity.ts create mode 100644 server/src/db/entities/DocumentSyncItems.entity.ts create mode 100644 server/src/db/entities/FileLinks.entity.ts create mode 100644 server/src/db/entities/GoogleApiUsage.entity.ts create mode 100644 server/src/db/entities/GooglePlacePhotoMeta.entity.ts create mode 100644 server/src/db/entities/HiddenCountries.entity.ts create mode 100644 server/src/db/entities/HiddenRegions.entity.ts create mode 100644 server/src/db/entities/IdempotencyKeys.entity.ts create mode 100644 server/src/db/entities/InviteTokens.entity.ts create mode 100644 server/src/db/entities/JourneyBooks.entity.ts create mode 100644 server/src/db/entities/JourneyContributors.entity.ts create mode 100644 server/src/db/entities/JourneyEntries.entity.ts create mode 100644 server/src/db/entities/JourneyEntryPhotos.entity.ts create mode 100644 server/src/db/entities/JourneyPhotos.entity.ts create mode 100644 server/src/db/entities/JourneyShareTokens.entity.ts create mode 100644 server/src/db/entities/JourneyTrips.entity.ts create mode 100644 server/src/db/entities/Journeys.entity.ts create mode 100644 server/src/db/entities/McpTokens.entity.ts create mode 100644 server/src/db/entities/Migrations.entity.ts create mode 100644 server/src/db/entities/NotificationChannelPreferences.entity.ts create mode 100644 server/src/db/entities/Notifications.entity.ts create mode 100644 server/src/db/entities/OauthClients.entity.ts create mode 100644 server/src/db/entities/OauthConsents.entity.ts create mode 100644 server/src/db/entities/OauthTokens.entity.ts create mode 100644 server/src/db/entities/PackingBags.entity.ts create mode 100644 server/src/db/entities/PackingCategoryAssignees.entity.ts create mode 100644 server/src/db/entities/PackingItemContributors.entity.ts create mode 100644 server/src/db/entities/PackingItems.entity.ts create mode 100644 server/src/db/entities/PackingTemplateCategories.entity.ts create mode 100644 server/src/db/entities/PackingTemplateItems.entity.ts create mode 100644 server/src/db/entities/PackingTemplates.entity.ts create mode 100644 server/src/db/entities/PasswordResetTokens.entity.ts create mode 100644 server/src/db/entities/PhotoProviderFields.entity.ts create mode 100644 server/src/db/entities/PhotoProviders.entity.ts create mode 100644 server/src/db/entities/Photos.entity.ts create mode 100644 server/src/db/entities/PlaceDetailsCache.entity.ts create mode 100644 server/src/db/entities/PlaceRatings.entity.ts create mode 100644 server/src/db/entities/PlaceRegions.entity.ts create mode 100644 server/src/db/entities/PlaceShadowPicks.entity.ts create mode 100644 server/src/db/entities/Places.entity.ts create mode 100644 server/src/db/entities/PluginActions.entity.ts create mode 100644 server/src/db/entities/PluginCapabilityAudit.entity.ts create mode 100644 server/src/db/entities/PluginEgressHosts.entity.ts create mode 100644 server/src/db/entities/PluginEntityMetadata.entity.ts create mode 100644 server/src/db/entities/PluginErrorLog.entity.ts create mode 100644 server/src/db/entities/PluginMetaMigrations.entity.ts create mode 100644 server/src/db/entities/PluginOauthState.entity.ts create mode 100644 server/src/db/entities/PluginOauthTokens.entity.ts create mode 100644 server/src/db/entities/PluginScheduledTasks.entity.ts create mode 100644 server/src/db/entities/PluginSettingsFields.entity.ts create mode 100644 server/src/db/entities/PluginUserConfig.entity.ts create mode 100644 server/src/db/entities/PluginUserErasureQueue.entity.ts create mode 100644 server/src/db/entities/Plugins.entity.ts create mode 100644 server/src/db/entities/PushSubscriptions.entity.ts create mode 100644 server/src/db/entities/ReservationDayPositions.entity.ts create mode 100644 server/src/db/entities/ReservationEndpoints.entity.ts create mode 100644 server/src/db/entities/ReservationTravelers.entity.ts create mode 100644 server/src/db/entities/Reservations.entity.ts create mode 100644 server/src/db/entities/RoadtripDayBoundaries.entity.ts create mode 100644 server/src/db/entities/RoadtripDayTracks.entity.ts create mode 100644 server/src/db/entities/RoadtripPreferences.entity.ts create mode 100644 server/src/db/entities/RoadtripVias.entity.ts create mode 100644 server/src/db/entities/RouteUsageDaily.entity.ts create mode 100644 server/src/db/entities/SchemaVersion.entity.ts create mode 100644 server/src/db/entities/SchoolHolidayCountries.entity.ts create mode 100644 server/src/db/entities/SchoolHolidayPeriods.entity.ts create mode 100644 server/src/db/entities/SchoolHolidayRegions.entity.ts create mode 100644 server/src/db/entities/Settings.entity.ts create mode 100644 server/src/db/entities/ShareTokens.entity.ts create mode 100644 server/src/db/entities/Tags.entity.ts create mode 100644 server/src/db/entities/TodoCategoryAssignees.entity.ts create mode 100644 server/src/db/entities/TodoItems.entity.ts create mode 100644 server/src/db/entities/TrekPhotoCacheMeta.entity.ts create mode 100644 server/src/db/entities/TrekPhotos.entity.ts create mode 100644 server/src/db/entities/TripAlbumLinks.entity.ts create mode 100644 server/src/db/entities/TripDocumentLinks.entity.ts create mode 100644 server/src/db/entities/TripFiles.entity.ts create mode 100644 server/src/db/entities/TripInviteTokens.entity.ts create mode 100644 server/src/db/entities/TripMembers.entity.ts create mode 100644 server/src/db/entities/TripPhotos.entity.ts create mode 100644 server/src/db/entities/Trips.entity.ts create mode 100644 server/src/db/entities/UserNoticeDismissals.entity.ts create mode 100644 server/src/db/entities/Users.entity.ts create mode 100644 server/src/db/entities/VacayCompanyHolidays.entity.ts create mode 100644 server/src/db/entities/VacayEntries.entity.ts create mode 100644 server/src/db/entities/VacayHolidayCalendars.entity.ts create mode 100644 server/src/db/entities/VacayPlanMembers.entity.ts create mode 100644 server/src/db/entities/VacayPlans.entity.ts create mode 100644 server/src/db/entities/VacayShares.entity.ts create mode 100644 server/src/db/entities/VacayUserColors.entity.ts create mode 100644 server/src/db/entities/VacayUserSettings.entity.ts create mode 100644 server/src/db/entities/VacayUserYears.entity.ts create mode 100644 server/src/db/entities/VacayYears.entity.ts create mode 100644 server/src/db/entities/VisitedCountries.entity.ts create mode 100644 server/src/db/entities/VisitedRegions.entity.ts create mode 100644 server/src/db/entities/WebauthnChallenges.entity.ts create mode 100644 server/src/db/entities/WebauthnCredentials.entity.ts create mode 100644 server/src/db/entities/index.ts create mode 100644 server/src/db/legacy-baseline.ts create mode 100644 server/src/db/migration-utils.ts delete mode 100644 server/src/db/migrations.ts create mode 100644 server/src/db/migrations/Migration20200101000000_baseline_schema.ts create mode 100644 server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts create mode 100644 server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts create mode 100644 server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts create mode 100644 server/src/db/migrations/Migration20200101000400_places_add_notes.ts create mode 100644 server/src/db/migrations/Migration20200101000500_places_add_image_url.ts create mode 100644 server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts create mode 100644 server/src/db/migrations/Migration20200101000700_days_add_title.ts create mode 100644 server/src/db/migrations/Migration20200101000800_reservations_add_status.ts create mode 100644 server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts create mode 100644 server/src/db/migrations/Migration20200101001000_reservations_add_type.ts create mode 100644 server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts create mode 100644 server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts create mode 100644 server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts create mode 100644 server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts create mode 100644 server/src/db/migrations/Migration20200101001500_users_add_avatar.ts create mode 100644 server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts create mode 100644 server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts create mode 100644 server/src/db/migrations/Migration20200101001800_users_add_last_login.ts create mode 100644 server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts create mode 100644 server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts create mode 100644 server/src/db/migrations/Migration20200101002100_places_add_end_time.ts create mode 100644 server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts create mode 100644 server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts create mode 100644 server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts create mode 100644 server/src/db/migrations/Migration20200101002500_create_collab_notes.ts create mode 100644 server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts create mode 100644 server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts create mode 100644 server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts create mode 100644 server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts create mode 100644 server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts create mode 100644 server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts create mode 100644 server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts create mode 100644 server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts create mode 100644 server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts create mode 100644 server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts create mode 100644 server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts create mode 100644 server/src/db/migrations/Migration20200101003800_create_packing_templates.ts create mode 100644 server/src/db/migrations/Migration20200101003900_create_packing_bags.ts create mode 100644 server/src/db/migrations/Migration20200101004000_create_visited_countries.ts create mode 100644 server/src/db/migrations/Migration20200101004100_create_bucket_list.ts create mode 100644 server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts create mode 100644 server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts create mode 100644 server/src/db/migrations/Migration20200101004400_create_file_links.ts create mode 100644 server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts create mode 100644 server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts create mode 100644 server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts create mode 100644 server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts create mode 100644 server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts create mode 100644 server/src/db/migrations/Migration20200101005000_create_share_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts create mode 100644 server/src/db/migrations/Migration20200101005200_create_audit_log.ts create mode 100644 server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts create mode 100644 server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts create mode 100644 server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts create mode 100644 server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts create mode 100644 server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts create mode 100644 server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts create mode 100644 server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts create mode 100644 server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts create mode 100644 server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts create mode 100644 server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts create mode 100644 server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts create mode 100644 server/src/db/migrations/Migration20200101010600_create_notifications.ts create mode 100644 server/src/db/migrations/Migration20200101010700_create_trip_photos.ts create mode 100644 server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts create mode 100644 server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts create mode 100644 server/src/db/migrations/Migration20200101011000_step_070.ts create mode 100644 server/src/db/migrations/Migration20200101011100_step_071.ts create mode 100644 server/src/db/migrations/Migration20200101011200_step_072.ts create mode 100644 server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts create mode 100644 server/src/db/migrations/Migration20200101011400_todo_items.ts create mode 100644 server/src/db/migrations/Migration20200101011500_backfill_addons.ts create mode 100644 server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts create mode 100644 server/src/db/migrations/Migration20200101011700_create_visited_regions.ts create mode 100644 server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts create mode 100644 server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts create mode 100644 server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts create mode 100644 server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts create mode 100644 server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts create mode 100644 server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts create mode 100644 server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts create mode 100644 server/src/db/migrations/Migration20200101012500_oauth_2.ts create mode 100644 server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts create mode 100644 server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts create mode 100644 server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts create mode 100644 server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts create mode 100644 server/src/db/migrations/Migration20200101013000_journey_addon.ts create mode 100644 server/src/db/migrations/Migration20200101013100_journal.ts create mode 100644 server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts create mode 100644 server/src/db/migrations/Migration20200101013300_journey_rebuild.ts create mode 100644 server/src/db/migrations/Migration20200101013400_journey_photos.ts create mode 100644 server/src/db/migrations/Migration20200101013500_journey_cover_image.ts create mode 100644 server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts create mode 100644 server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts create mode 100644 server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts create mode 100644 server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts create mode 100644 server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts create mode 100644 server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts create mode 100644 server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts create mode 100644 server/src/db/migrations/Migration20200101014400_system_notices.ts create mode 100644 server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts create mode 100644 server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts create mode 100644 server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts create mode 100644 server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts create mode 100644 server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts create mode 100644 server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts create mode 100644 server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts create mode 100644 server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts create mode 100644 server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts create mode 100644 server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts create mode 100644 server/src/db/migrations/Migration20200101015500_password_reset.ts create mode 100644 server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts create mode 100644 server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts create mode 100644 server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts create mode 100644 server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts create mode 100644 server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts create mode 100644 server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts create mode 100644 server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts create mode 100644 server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts create mode 100644 server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts create mode 100644 server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts create mode 100644 server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts create mode 100644 server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts create mode 100644 server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts create mode 100644 server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts create mode 100644 server/src/db/migrations/Migration20200101021000_oauth_2.ts create mode 100644 server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts create mode 100644 server/src/db/migrations/Migration20200101021200_costs_rework.ts create mode 100644 server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts create mode 100644 server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts create mode 100644 server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts create mode 100644 server/src/db/migrations/Migration20200101021600_step_136.ts create mode 100644 server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts create mode 100644 server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts create mode 100644 server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts create mode 100644 server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts create mode 100644 server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts create mode 100644 server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts create mode 100644 server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts create mode 100644 server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts create mode 100644 server/src/db/migrations/Migration20200101022500_video_support.ts create mode 100644 server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts create mode 100644 server/src/db/migrations/Migration20200101022700_private_packing_items.ts create mode 100644 server/src/db/migrations/Migration20200101022800_guest_members.ts create mode 100644 server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts create mode 100644 server/src/db/migrations/Migration20200101023000_collections_addon.ts create mode 100644 server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts create mode 100644 server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts create mode 100644 server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts create mode 100644 server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts create mode 100644 server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts create mode 100644 server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts create mode 100644 server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts create mode 100644 server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts create mode 100644 server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts create mode 100644 server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts create mode 100644 server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts create mode 100644 server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts create mode 100644 server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts create mode 100644 server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts create mode 100644 server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts create mode 100644 server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts create mode 100644 server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts create mode 100644 server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts create mode 100644 server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts create mode 100644 server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts create mode 100644 server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts create mode 100644 server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts create mode 100644 server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts create mode 100644 server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts create mode 100644 server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts create mode 100644 server/src/db/migrations/Migration20200101025600_half_vacation_days.ts create mode 100644 server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts create mode 100644 server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts create mode 100644 server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts create mode 100644 server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts create mode 100644 server/src/db/migrations/Migration20200101030100_1517.ts create mode 100644 server/src/db/migrations/Migration20200101030200_comp_flex_days.ts create mode 100644 server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts create mode 100644 server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts create mode 100644 server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts create mode 100644 server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts create mode 100644 server/src/db/migrations/Migration20200101030700_note_colours.ts create mode 100644 server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts create mode 100644 server/src/db/migrations/Migration20200101030900_1298.ts create mode 100644 server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts create mode 100644 server/src/db/migrations/Migration20200101031100_1939.ts create mode 100644 server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts create mode 100644 server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts create mode 100644 server/src/db/migrations/Migration20200101031400_trek_studio_books.ts create mode 100644 server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts create mode 100644 server/src/db/migrations/Migration20200101031600_storage_slice_2.ts create mode 100644 server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts create mode 100644 server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts create mode 100644 server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts create mode 100644 server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts create mode 100644 server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts create mode 100644 server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts create mode 100644 server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts create mode 100644 server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts create mode 100644 server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts create mode 100644 server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts create mode 100644 server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts create mode 100644 server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts create mode 100644 server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts create mode 100644 server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts create mode 100644 server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts create mode 100644 server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts create mode 100644 server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts create mode 100644 server/src/db/migrations/Migration20200101033400_route_usage_counters.ts create mode 100644 server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts create mode 100644 server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts create mode 100644 server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts create mode 100644 server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts create mode 100644 server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts create mode 100644 server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts create mode 100644 server/src/db/migrations/Migration20200101034100_amap.ts create mode 100644 server/src/db/migrations/Migration20200101034200_dawarich_connection.ts create mode 100644 server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts create mode 100644 server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts create mode 100644 server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts create mode 100644 server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts create mode 100644 server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts create mode 100644 server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts create mode 100644 server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts create mode 100644 server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts create mode 100644 server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts create mode 100644 server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts create mode 100644 server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts create mode 100644 server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts create mode 100644 server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts create mode 100644 server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts create mode 100644 server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts create mode 100644 server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts create mode 100644 server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts create mode 100644 server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts create mode 100644 server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts create mode 100644 server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts create mode 100644 server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts create mode 100644 server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts create mode 100644 server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts create mode 100644 server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts create mode 100644 server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts create mode 100644 server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts create mode 100644 server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts create mode 100644 server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts create mode 100644 server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts create mode 100644 server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts create mode 100644 server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts create mode 100644 server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts create mode 100644 server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts create mode 100644 server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts create mode 100644 server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts create mode 100644 server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts create mode 100644 server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts create mode 100644 server/src/db/nul-safe-sqlite-platform.ts create mode 100644 server/src/db/orm-driver.ts create mode 100644 server/src/db/orm.ts create mode 100644 server/src/db/repositories/Addons.repository.ts create mode 100644 server/src/db/repositories/AppSettings.repository.ts create mode 100644 server/src/db/repositories/AssignmentParticipants.repository.ts create mode 100644 server/src/db/repositories/AuditLog.repository.ts create mode 100644 server/src/db/repositories/BucketList.repository.ts create mode 100644 server/src/db/repositories/BudgetCategoryOrder.repository.ts create mode 100644 server/src/db/repositories/BudgetItemMembers.repository.ts create mode 100644 server/src/db/repositories/BudgetItemPayers.repository.ts create mode 100644 server/src/db/repositories/BudgetItems.repository.ts create mode 100644 server/src/db/repositories/BudgetSettlements.repository.ts create mode 100644 server/src/db/repositories/Categories.repository.ts create mode 100644 server/src/db/repositories/CollabLinks.repository.ts create mode 100644 server/src/db/repositories/CollabMessageReactions.repository.ts create mode 100644 server/src/db/repositories/CollabMessages.repository.ts create mode 100644 server/src/db/repositories/CollabNotes.repository.ts create mode 100644 server/src/db/repositories/CollabPollVotes.repository.ts create mode 100644 server/src/db/repositories/CollabPolls.repository.ts create mode 100644 server/src/db/repositories/CollectionLabels.repository.ts create mode 100644 server/src/db/repositories/CollectionMembers.repository.ts create mode 100644 server/src/db/repositories/CollectionPlaceRatings.repository.ts create mode 100644 server/src/db/repositories/CollectionPlaces.repository.ts create mode 100644 server/src/db/repositories/Collections.repository.ts create mode 100644 server/src/db/repositories/DawarichConnections.repository.ts create mode 100644 server/src/db/repositories/DawarichVisitSuggestions.repository.ts create mode 100644 server/src/db/repositories/DayAccommodations.repository.ts create mode 100644 server/src/db/repositories/DayAssignments.repository.ts create mode 100644 server/src/db/repositories/DayNotes.repository.ts create mode 100644 server/src/db/repositories/Days.repository.ts create mode 100644 server/src/db/repositories/DemoRepository.ts create mode 100644 server/src/db/repositories/DocumentConnections.repository.ts create mode 100644 server/src/db/repositories/DocumentProviderFields.repository.ts create mode 100644 server/src/db/repositories/DocumentProviders.repository.ts create mode 100644 server/src/db/repositories/DocumentSyncItems.repository.ts create mode 100644 server/src/db/repositories/FileLinks.repository.ts create mode 100644 server/src/db/repositories/GoogleApiUsage.repository.ts create mode 100644 server/src/db/repositories/GooglePlacePhotoMeta.repository.ts create mode 100644 server/src/db/repositories/HiddenCountries.repository.ts create mode 100644 server/src/db/repositories/HiddenRegions.repository.ts create mode 100644 server/src/db/repositories/IdempotencyKeys.repository.ts create mode 100644 server/src/db/repositories/InviteTokens.repository.ts create mode 100644 server/src/db/repositories/JourneyBooks.repository.ts create mode 100644 server/src/db/repositories/JourneyContributors.repository.ts create mode 100644 server/src/db/repositories/JourneyEntries.repository.ts create mode 100644 server/src/db/repositories/JourneyEntryPhotos.repository.ts create mode 100644 server/src/db/repositories/JourneyPhotos.repository.ts create mode 100644 server/src/db/repositories/JourneyShareTokens.repository.ts create mode 100644 server/src/db/repositories/JourneyTrips.repository.ts create mode 100644 server/src/db/repositories/Journeys.repository.ts create mode 100644 server/src/db/repositories/MaintenanceRepository.ts create mode 100644 server/src/db/repositories/McpTokens.repository.ts create mode 100644 server/src/db/repositories/Migrations.repository.ts create mode 100644 server/src/db/repositories/NotificationChannelPreferences.repository.ts create mode 100644 server/src/db/repositories/Notifications.repository.ts create mode 100644 server/src/db/repositories/OauthClients.repository.ts create mode 100644 server/src/db/repositories/OauthConsents.repository.ts create mode 100644 server/src/db/repositories/OauthTokens.repository.ts create mode 100644 server/src/db/repositories/PackingBags.repository.ts create mode 100644 server/src/db/repositories/PackingCategoryAssignees.repository.ts create mode 100644 server/src/db/repositories/PackingItemContributors.repository.ts create mode 100644 server/src/db/repositories/PackingItems.repository.ts create mode 100644 server/src/db/repositories/PackingTemplateCategories.repository.ts create mode 100644 server/src/db/repositories/PackingTemplateItems.repository.ts create mode 100644 server/src/db/repositories/PackingTemplates.repository.ts create mode 100644 server/src/db/repositories/PasswordResetTokens.repository.ts create mode 100644 server/src/db/repositories/PhotoProviderFields.repository.ts create mode 100644 server/src/db/repositories/PhotoProviders.repository.ts create mode 100644 server/src/db/repositories/Photos.repository.ts create mode 100644 server/src/db/repositories/PlaceDetailsCache.repository.ts create mode 100644 server/src/db/repositories/PlaceRatings.repository.ts create mode 100644 server/src/db/repositories/PlaceRegions.repository.ts create mode 100644 server/src/db/repositories/PlaceShadowPicks.repository.ts create mode 100644 server/src/db/repositories/Places.repository.ts create mode 100644 server/src/db/repositories/PluginActions.repository.ts create mode 100644 server/src/db/repositories/PluginCapabilityAudit.repository.ts create mode 100644 server/src/db/repositories/PluginEgressHosts.repository.ts create mode 100644 server/src/db/repositories/PluginEntityMetadata.repository.ts create mode 100644 server/src/db/repositories/PluginErrorLog.repository.ts create mode 100644 server/src/db/repositories/PluginMetaMigrations.repository.ts create mode 100644 server/src/db/repositories/PluginOauthState.repository.ts create mode 100644 server/src/db/repositories/PluginOauthTokens.repository.ts create mode 100644 server/src/db/repositories/PluginScheduledTasks.repository.ts create mode 100644 server/src/db/repositories/PluginSettingsFields.repository.ts create mode 100644 server/src/db/repositories/PluginUserConfig.repository.ts create mode 100644 server/src/db/repositories/PluginUserErasureQueue.repository.ts create mode 100644 server/src/db/repositories/Plugins.repository.ts create mode 100644 server/src/db/repositories/PushSubscriptions.repository.ts create mode 100644 server/src/db/repositories/ReservationDayPositions.repository.ts create mode 100644 server/src/db/repositories/ReservationEndpoints.repository.ts create mode 100644 server/src/db/repositories/ReservationTravelers.repository.ts create mode 100644 server/src/db/repositories/Reservations.repository.ts create mode 100644 server/src/db/repositories/RoadtripDayBoundaries.repository.ts create mode 100644 server/src/db/repositories/RoadtripDayTracks.repository.ts create mode 100644 server/src/db/repositories/RoadtripPreferences.repository.ts create mode 100644 server/src/db/repositories/RoadtripVias.repository.ts create mode 100644 server/src/db/repositories/RouteUsageDaily.repository.ts create mode 100644 server/src/db/repositories/SchemaVersion.repository.ts create mode 100644 server/src/db/repositories/SchoolHolidayCountries.repository.ts create mode 100644 server/src/db/repositories/SchoolHolidayPeriods.repository.ts create mode 100644 server/src/db/repositories/SchoolHolidayRegions.repository.ts create mode 100644 server/src/db/repositories/Settings.repository.ts create mode 100644 server/src/db/repositories/ShareTokens.repository.ts create mode 100644 server/src/db/repositories/Tags.repository.ts create mode 100644 server/src/db/repositories/TodoCategoryAssignees.repository.ts create mode 100644 server/src/db/repositories/TodoItems.repository.ts create mode 100644 server/src/db/repositories/TrekPhotoCacheMeta.repository.ts create mode 100644 server/src/db/repositories/TrekPhotos.repository.ts create mode 100644 server/src/db/repositories/TripAlbumLinks.repository.ts create mode 100644 server/src/db/repositories/TripDocumentLinks.repository.ts create mode 100644 server/src/db/repositories/TripFiles.repository.ts create mode 100644 server/src/db/repositories/TripInviteTokens.repository.ts create mode 100644 server/src/db/repositories/TripMembers.repository.ts create mode 100644 server/src/db/repositories/TripPhotos.repository.ts create mode 100644 server/src/db/repositories/Trips.repository.ts create mode 100644 server/src/db/repositories/UserNoticeDismissals.repository.ts create mode 100644 server/src/db/repositories/Users.repository.ts create mode 100644 server/src/db/repositories/VacayCompanyHolidays.repository.ts create mode 100644 server/src/db/repositories/VacayEntries.repository.ts create mode 100644 server/src/db/repositories/VacayHolidayCalendars.repository.ts create mode 100644 server/src/db/repositories/VacayPlanMembers.repository.ts create mode 100644 server/src/db/repositories/VacayPlans.repository.ts create mode 100644 server/src/db/repositories/VacayShares.repository.ts create mode 100644 server/src/db/repositories/VacayUserColors.repository.ts create mode 100644 server/src/db/repositories/VacayUserSettings.repository.ts create mode 100644 server/src/db/repositories/VacayUserYears.repository.ts create mode 100644 server/src/db/repositories/VacayYears.repository.ts create mode 100644 server/src/db/repositories/VisitedCountries.repository.ts create mode 100644 server/src/db/repositories/VisitedRegions.repository.ts create mode 100644 server/src/db/repositories/WebauthnChallenges.repository.ts create mode 100644 server/src/db/repositories/WebauthnCredentials.repository.ts create mode 100644 server/src/db/repositories/_shared/collection-role.ts create mode 100644 server/src/db/repositories/_shared/owned-lookup.ts create mode 100644 server/src/db/repositories/_shared/packing-visibility.ts create mode 100644 server/src/db/repositories/_shared/presence-set.ts create mode 100644 server/src/db/repositories/_shared/reservation-travelers-owns.ts create mode 100644 server/src/db/repositories/_shared/reservation-visibility.ts create mode 100644 server/src/db/repositories/_shared/rows.ts create mode 100644 server/src/db/repositories/_shared/trek-repository.ts create mode 100644 server/src/db/repositories/_shared/trip-access.ts create mode 100644 server/src/db/schema-snapshot.ts delete mode 100644 server/src/db/schema.ts create mode 100644 server/src/db/seeders/AddonSeeder.ts create mode 100644 server/src/db/seeders/AdminSeeder.ts create mode 100644 server/src/db/seeders/CategorySeeder.ts create mode 100644 server/src/db/seeders/DatabaseSeeder.ts create mode 100644 server/src/db/seeders/DocumentProviderSeeder.ts create mode 100644 server/src/db/seeders/PhotoProviderSeeder.ts create mode 100644 server/src/db/seeders/SchemaVersionSeeder.ts delete mode 100644 server/src/db/seeds.ts create mode 100644 server/src/db/types/db-timestamp.type.ts create mode 100644 server/src/db/types/index.ts create mode 100644 server/src/mikro-orm.config.ts create mode 100644 server/src/nest/common/demo.module.ts create mode 100644 server/src/nest/common/demo.service.ts create mode 100644 server/src/nest/common/row-id.ts delete mode 100644 server/src/nest/database/database.module.ts delete mode 100644 server/src/nest/database/database.service.ts delete mode 100644 server/src/nest/database/database.tokens.ts create mode 100644 server/src/nest/database/orm.module.ts create mode 100644 server/src/nest/database/request-context.ts create mode 100644 server/src/nest/database/unit-of-work.ts create mode 100644 server/src/nest/photos/trek-photo-registration.service.ts delete mode 100644 server/src/nest/photos/trek-photos.repository.ts create mode 100644 server/src/nest/plugins/host/rpc-kit/row-shape.ts create mode 100644 server/src/nest/plugins/user-erasure-enqueue.ts delete mode 100644 server/src/nest/reservations/reservation-visibility.ts delete mode 100644 server/src/nest/reservations/reservations-read.repository.ts create mode 100644 server/src/nest/reservations/reservations-read.service.ts delete mode 100644 server/src/nest/trips/trip-select.ts create mode 100644 server/tests/build-schema-snapshot.ts create mode 100644 server/tests/fixtures/legacy/legacy-v066.sql create mode 100644 server/tests/fixtures/legacy/legacy-v082.sql create mode 100644 server/tests/fixtures/legacy/legacy-v205.sql create mode 100644 server/tests/fixtures/legacy/legacy-v242.sql create mode 100644 server/tests/global-setup.ts create mode 100644 server/tests/helpers/atlas-repos.ts create mode 100644 server/tests/helpers/budget-repos.ts create mode 100644 server/tests/helpers/collab-repos.ts create mode 100644 server/tests/helpers/dawarich-repos.ts create mode 100644 server/tests/helpers/db-mock.ts create mode 100644 server/tests/helpers/doc-sync-repos.ts create mode 100644 server/tests/helpers/files-repos.ts create mode 100644 server/tests/helpers/journey-repos.ts create mode 100644 server/tests/helpers/journey-share-repos.ts create mode 100644 server/tests/helpers/legacy-fixture.ts create mode 100644 server/tests/helpers/legacy-upgrade-suite.ts create mode 100644 server/tests/helpers/migration-step.ts create mode 100644 server/tests/helpers/notifications-repos.ts create mode 100644 server/tests/helpers/packing-repos.ts create mode 100644 server/tests/helpers/school-holidays-repos.ts create mode 100644 server/tests/helpers/share-repos.ts create mode 100644 server/tests/helpers/test-addons.ts create mode 100644 server/tests/helpers/test-orm.ts create mode 100644 server/tests/helpers/test-uow.ts create mode 100644 server/tests/helpers/todo-repos.ts create mode 100644 server/tests/helpers/vacay-repos.ts create mode 100644 server/tests/integration/airtrail-sync-request-context.test.ts create mode 100644 server/tests/integration/async-closure-truthiness-guards.test.ts create mode 100644 server/tests/integration/atlas-geocode-request-context.test.ts create mode 100644 server/tests/integration/boot-sweeps-request-context.test.ts create mode 100644 server/tests/integration/demo-reset-connection-swap.spike.test.ts create mode 100644 server/tests/integration/demo-seed-request-context.test.ts create mode 100644 server/tests/integration/doc-sync-webhook-request-context.test.ts create mode 100644 server/tests/integration/feeds-request-context.test.ts create mode 100644 server/tests/integration/journey-place-order.test.ts create mode 100644 server/tests/integration/legacy-upgrade-v066.test.ts create mode 100644 server/tests/integration/legacy-upgrade-v082.test.ts create mode 100644 server/tests/integration/legacy-upgrade-v205.test.ts create mode 100644 server/tests/integration/legacy-upgrade-v242.test.ts create mode 100644 server/tests/integration/orm-request-context-seams.test.ts create mode 100644 server/tests/integration/passkey.test.ts create mode 100644 server/tests/integration/plugins/plugin-runtime.task2-conversion.test.ts create mode 100644 server/tests/integration/plugins/plugin-scheduler-sweep.test.ts create mode 100644 server/tests/integration/storage-health-notifier-request-context.test.ts create mode 100644 server/tests/integration/trip-access-primitives.test.ts create mode 100644 server/tests/integration/uploads-photo-request-context.test.ts create mode 100644 server/tests/unit/app-config/nest-log-levels.test.ts create mode 100644 server/tests/unit/db/dialect/sql-functions.test.ts create mode 100644 server/tests/unit/db/entities-index.test.ts create mode 100644 server/tests/unit/db/entities/primary-relations.spike.test.ts create mode 100644 server/tests/unit/db/entity-class-fields.test.ts create mode 100644 server/tests/unit/db/entity-schema-parity.test.ts create mode 100644 server/tests/unit/db/generate-entities.test.ts create mode 100644 server/tests/unit/db/legacy-baseline.test.ts delete mode 100644 server/tests/unit/db/migration-hygiene.test.ts delete mode 100644 server/tests/unit/db/migration-version-atomicity.test.ts create mode 100644 server/tests/unit/db/nul-safe-sqlite-platform.test.ts create mode 100644 server/tests/unit/db/repositories/Addons.repository.test.ts create mode 100644 server/tests/unit/db/repositories/AppSettings.repository.test.ts create mode 100644 server/tests/unit/db/repositories/AssignmentParticipants.repository.test.ts create mode 100644 server/tests/unit/db/repositories/AuditLog.repository.test.ts create mode 100644 server/tests/unit/db/repositories/BucketList.repository.test.ts create mode 100644 server/tests/unit/db/repositories/BudgetItems.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Categories.repository.test.ts create mode 100644 server/tests/unit/db/repositories/CollabMessages.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DawarichConnections.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DayAccommodations.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DayAssignments.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DayNotes.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Days.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DemoRepository.test.ts create mode 100644 server/tests/unit/db/repositories/DocumentProviders.repository.test.ts create mode 100644 server/tests/unit/db/repositories/DocumentSyncItems.repository.test.ts create mode 100644 server/tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts create mode 100644 server/tests/unit/db/repositories/HiddenRegions.repository.test.ts create mode 100644 server/tests/unit/db/repositories/IdempotencyKeys.repository.test.ts create mode 100644 server/tests/unit/db/repositories/InviteTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/MaintenanceRepository.test.ts create mode 100644 server/tests/unit/db/repositories/McpTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Notifications.repository.test.ts create mode 100644 server/tests/unit/db/repositories/OauthClients.repository.test.ts create mode 100644 server/tests/unit/db/repositories/OauthConsents.repository.test.ts create mode 100644 server/tests/unit/db/repositories/OauthTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PackingItems.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PasswordResetTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PhotoProviderFields.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PhotoProviders.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceRatings.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceRegions.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Places.repository.test.ts create mode 100644 server/tests/unit/db/repositories/PluginOauthTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/ReservationDayPositions.repository.test.ts create mode 100644 server/tests/unit/db/repositories/ReservationEndpoints.repository.test.ts create mode 100644 server/tests/unit/db/repositories/ReservationTravelers.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Reservations.repository.test.ts create mode 100644 server/tests/unit/db/repositories/RoadtripDayBoundaries.repository.test.ts create mode 100644 server/tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts create mode 100644 server/tests/unit/db/repositories/RoadtripPreferences.repository.test.ts create mode 100644 server/tests/unit/db/repositories/RoadtripVias.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Settings.repository.test.ts create mode 100644 server/tests/unit/db/repositories/ShareTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Tags.repository.test.ts create mode 100644 server/tests/unit/db/repositories/TodoItems.repository.test.ts create mode 100644 server/tests/unit/db/repositories/TripInviteTokens.repository.test.ts create mode 100644 server/tests/unit/db/repositories/TripMembers.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Trips.repository.test.ts create mode 100644 server/tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts create mode 100644 server/tests/unit/db/repositories/Users.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayPlanMembers.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayPlans.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayShares.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayUserSettings.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayUserYears.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VacayYears.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VisitedCountries.repository.test.ts create mode 100644 server/tests/unit/db/repositories/VisitedRegions.repository.test.ts create mode 100644 server/tests/unit/db/repositories/WebauthnChallenges.repository.test.ts create mode 100644 server/tests/unit/db/repositories/WebauthnCredentials.repository.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/collection-role.parity.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/owned-lookup.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts create mode 100644 server/tests/unit/db/repositories/_shared/reservation-visibility.test.ts create mode 100644 server/tests/unit/db/repositories/addons-config-encryption.test.ts create mode 100644 server/tests/unit/db/repositories/atlas-branch-coverage.test.ts create mode 100644 server/tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts create mode 100644 server/tests/unit/db/repositories/identity-map-writeback.test.ts create mode 100644 server/tests/unit/db/repositories/trek-repository.test.ts create mode 100644 server/tests/unit/db/repositories/vacay-branch-coverage.test.ts create mode 100644 server/tests/unit/db/restore-migrates-forward.test.ts create mode 100644 server/tests/unit/db/schema-version-seeder.test.ts create mode 100644 server/tests/unit/db/test-orm.test.ts create mode 100644 server/tests/unit/db/transaction-yield.test.ts create mode 100644 server/tests/unit/db/types/db-timestamp.type.test.ts create mode 100644 server/tests/unit/eslint-rules/no-promise-as-value.test.ts create mode 100644 server/tests/unit/helpers/test-uow.test.ts create mode 100644 server/tests/unit/nest/airports/airports.service.test.ts create mode 100644 server/tests/unit/nest/airtrail-sync.job.test.ts create mode 100644 server/tests/unit/nest/airtrail.service.test.ts create mode 100644 server/tests/unit/nest/common/row-id.test.ts delete mode 100644 server/tests/unit/nest/database-service.delegation.test.ts delete mode 100644 server/tests/unit/nest/database-service.test.ts create mode 100644 server/tests/unit/nest/database/request-context.test.ts create mode 100644 server/tests/unit/nest/database/unit-of-work.d5.test.ts create mode 100644 server/tests/unit/nest/database/unit-of-work.test.ts create mode 100644 server/tests/unit/nest/demo-service-survivor-sites.test.ts create mode 100644 server/tests/unit/nest/demo.service.test.ts create mode 100644 server/tests/unit/nest/files/upload-filters.test.ts create mode 100644 server/tests/unit/nest/memories/journey-thumbs.job.test.ts create mode 100644 server/tests/unit/nest/memories/trek-photo-cache.job.test.ts create mode 100644 server/tests/unit/nest/place-photos/place-photo-cache.job.test.ts create mode 100644 server/tests/unit/nest/query-helpers.service.test.ts create mode 100644 server/tests/unit/nest/school-holidays.service.test.ts create mode 100644 server/tests/unit/nest/system-notices.branch-coverage.test.ts create mode 100644 server/tests/unit/nest/to-client-user.parity.test.ts create mode 100644 server/tests/unit/nest/trek-photo-registration.service.test.ts delete mode 100644 server/tests/unit/nest/trek-photos.repository.test.ts create mode 100644 server/tests/unit/plugins/plugin-host-state.test.ts create mode 100644 server/tests/unit/plugins/plugin-repository-sql-parity.test.ts create mode 100644 server/tests/unit/plugins/plugin-route-normalize.test.ts create mode 100644 server/tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts create mode 100644 server/tests/unit/plugins/request-context.test.ts create mode 100644 server/tests/unit/plugins/rpc-kit/row-shape.test.ts create mode 100644 server/tests/unit/scripts/db-call-graph.test.ts create mode 100644 server/tsconfig.scripts.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d7a54e81..713a2165f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -180,9 +180,26 @@ jobs: # catches it. run: cd server && npm run typecheck:tests + - name: Typecheck entity generator + # tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts; + # gen-plugin-facts.ts uses import.meta and does not compile under this + # config (needs a newer --module target), so the gate is named for what + # it actually covers rather than the whole directory. + run: cd server && npm run typecheck:scripts + + - name: Entity files match the generator + run: cd server && npm run check:entities + - name: Lint + # Type-aware rules build the src + tests program, which outgrows + # Node's default ~4 GB heap on the runner. + env: + NODE_OPTIONS: --max-old-space-size=8192 run: cd server && npm run lint:check + - name: DB call-graph gate (no sync DB-touching methods, no raw transactions) + run: cd server && npm run db:call-graph -- --sync --tx + client-quality: name: Client Types & Lint runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 49dfc82f7..d88aa9aea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler). -- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. +- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. - **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub. - **MCP**: tools are declared in `.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`. - **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`. @@ -90,7 +90,7 @@ These principles come out of a full-repo audit and shape all new code: - **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger. - **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. -- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip. +- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. - **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state. - **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents. - **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds. diff --git a/client/package.json b/client/package.json index c236755db..47202b784 100644 --- a/client/package.json +++ b/client/package.json @@ -35,74 +35,74 @@ "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", - "@fontsource/geist-sans": "^5.2.5", + "@fontsource/geist-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", "@fontsource/lora": "^5.3.0", "@fontsource/museomoderno": "^5.3.0", "@fontsource/playfair-display": "^5.3.0", - "@fontsource/poppins": "^5.2.7", + "@fontsource/poppins": "^5.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", - "@simplewebauthn/browser": "^13.1.2", + "@simplewebauthn/browser": "^13.3.0", "@trek/shared": "*", - "axios": "^1.6.7", - "dexie": "^4.4.2", + "axios": "^1.20.0", + "dexie": "^4.4.6", "drag-drop-touch": "^1.3.1", - "heic-to": "^1.4.2", + "heic-to": "^1.5.2", "iso-3166-2": "^1.0.0", "leaflet": "^1.9.4", - "lucide-react": "^0.344.0", - "mapbox-gl": "^3.22.0", + "lucide-react": "^0.577.0", + "mapbox-gl": "^3.31.0", "maplibre-gl": "5.24.0", - "marked": "^18.0.0", + "marked": "^18.0.13", "plyr": "^3.8.4", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-dropzone": "^14.4.1", "react-leaflet": "^5.0.0", "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "tz-lookup": "^6.1.25", - "zod": "^4.3.6", - "zustand": "^5.0.14" + "zod": "^4.6.5", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.60.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^6.10.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/leaflet": "^1.9.8", - "@types/node": "^25.9.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@types/leaflet": "^1.9.22", + "@types/node": "^25.9.8", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", - "autoprefixer": "^10.4.18", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", + "autoprefixer": "^10.6.1", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "fake-indexeddb": "^6.2.5", - "jsdom": "^29.0.1", - "msw": "^2.13.0", - "postcss": "^8.4.35", - "prettier": "^3.8.3", + "jsdom": "^29.1.1", + "msw": "^2.15.0", + "postcss": "^8.5.28", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "rollup-plugin-visualizer": "^7.0.1", + "prettier-plugin-tailwindcss": "^0.8.1", + "rollup-plugin-visualizer": "^7.1.1", "sharp": "^0.35.4", - "tailwindcss": "^3.4.1", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", - "vite": "8.1.0", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "8.3.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.11" } diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx index d77fbabcc..a932a2c3c 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -290,8 +290,8 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => { const { baseElement } = render() expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() - expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)') - expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull() + expect(baseElement.querySelector('svg.lucide-triangle-alert')).toHaveAttribute('stroke', 'var(--warning)') + expect(baseElement.querySelector('svg.lucide-circle-check')).toBeNull() expect(baseElement.querySelector('.animate-spin')).toBeNull() }) @@ -300,8 +300,8 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => { const { baseElement } = render() // The theme's own success colour, so it follows light and dark. - expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)') - expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull() + expect(baseElement.querySelector('svg.lucide-circle-check')).toHaveAttribute('stroke', 'var(--success)') + expect(baseElement.querySelector('svg.lucide-triangle-alert')).toBeNull() }) }) diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 9918b05a0..b6922ea22 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -335,7 +335,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the MoreHorizontal context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -447,7 +447,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the MoreHorizontal context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -529,7 +529,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the category context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -1046,7 +1046,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open context menu and click Delete List - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); await user.click(moreBtn!); await user.click(await screen.findByText('Delete List')); diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx index f8e848b9d..3bffc1810 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx @@ -48,7 +48,7 @@ function setup(overrides: Partial = {}) { /** The category header owns the first MoreHorizontal; item rows render their own. */ function openCategoryMenu(container: HTMLElement) { - const btn = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')! + const btn = container.querySelectorAll('svg.lucide-ellipsis')[0].closest('button')! fireEvent.click(btn) return btn } @@ -303,7 +303,7 @@ describe('KategorieGruppe — bulk actions', () => { it('FE-W5CAT-015a: the menu trigger highlights on hover', () => { const { container } = setup() - const trigger = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')! + const trigger = container.querySelectorAll('svg.lucide-ellipsis')[0].closest('button')! fireEvent.mouseEnter(trigger) expect(trigger.style.color).toBe('var(--text-secondary)') diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index a2b1dd22f..a7af47217 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -2582,7 +2582,7 @@ describe('DayPlanSidebar', () => { // ferry from IJmuiden to the Port of Tyne in the evening. The slot written here is the // one every other reader takes from then on, the road trip included. const crossing = (newcastleAt: string | null, withAmsterdam = true) => { - const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' }) + const day = buildDay({ id: 10, date: '2025-10-06', title: 'Day 2' }) const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 }) const newcastle = buildPlace({ id: 2, name: 'Newcastle', lat: 54.9783, lng: -1.6178, place_time: newcastleAt }) const stops = [ @@ -2591,7 +2591,7 @@ describe('DayPlanSidebar', () => { ].filter(a => withAmsterdam || a.id !== 11) const ferry = buildReservation({ id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10, - reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00', + reservation_time: '2025-10-06T17:30', reservation_end_time: '2025-10-06T23:00', endpoints: [ { role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null }, { role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null }, @@ -2632,12 +2632,12 @@ describe('DayPlanSidebar', () => { // Worked out over Amsterdam alone, the clock closed the day at 1.5: behind the hotel // for the road trip, which then drove to Newcastle overland before the crossing. const { reservationsApi } = await import('../../api/client') - const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' }) + const day = buildDay({ id: 10, date: '2025-10-06', title: 'Day 2' }) const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 }) const hotel = buildPlace({ id: 3, name: 'Hotel Newcastle', lat: 54.975, lng: -1.61 }) const ferry = buildReservation({ id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10, - reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00', + reservation_time: '2025-10-06T17:30', reservation_end_time: '2025-10-06T23:00', endpoints: [ { role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null }, { role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null }, @@ -4012,9 +4012,9 @@ describe('DayPlanSidebar', () => { // Day 2 checks out of a Munich hotel and into a Hamburg one, and nothing else is // planned on it. The booked night's own stop never reaches the list. const movingDays = [ - buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }), - buildDay({ id: 11, date: '2026-11-04', title: 'Day 2' }), - buildDay({ id: 12, date: '2026-11-05', title: 'Day 3' }), + buildDay({ id: 10, date: '2025-11-03', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-11-04', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-11-05', title: 'Day 3' }), ] const stays: Accommodation[] = [ { id: 1, trip_id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.137, place_lng: 11.575, place_name: 'Hotel A' }, @@ -4024,7 +4024,7 @@ describe('DayPlanSidebar', () => { ({ role, sequence: role === 'from' ? 0 : 1, name, code: null, lat, lng, timezone: null, local_date: null, local_time: null }) const flight = (located: boolean) => buildReservation({ id: 77, type: 'flight', title: 'LH 2078', day_id: 11, end_day_id: 11, - reservation_time: '2026-11-04T15:15:00', reservation_end_time: '2026-11-04T17:20:00', + reservation_time: '2025-11-04T15:15:00', reservation_end_time: '2025-11-04T17:20:00', endpoints: located ? [airport('from', 'MUC', 48.353, 11.786), airport('to', 'HAM', 53.63, 9.988)] : [], }) @@ -4062,7 +4062,7 @@ describe('DayPlanSidebar', () => { const user = userEvent.setup() const { generateGoogleMapsUrl } = await import('../Map/RouteCalculator') const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) - const day = buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }) + const day = buildDay({ id: 10, date: '2025-11-03', title: 'Day 1' }) const assignments = { '10': [ buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Elbphilharmonie', lat: 53.541, lng: 9.984 }) }), diff --git a/client/src/utils/iconMarkup.parity.test.ts b/client/src/utils/iconMarkup.parity.test.ts index bce518492..1f116a179 100644 --- a/client/src/utils/iconMarkup.parity.test.ts +++ b/client/src/utils/iconMarkup.parity.test.ts @@ -14,9 +14,12 @@ import { renderIconMarkup } from './iconMarkup' type IconComponent = Parameters[0] +// `Icon` is lucide's generic base component, not an icon: it renders whatever +// `iconNode` it is handed and throws without one. const ICONS = Object.entries(lucide).filter( ([name, value]) => /^[A-Z]/.test(name) && + name !== 'Icon' && typeof value === 'object' && value !== null && (value as { $$typeof?: symbol }).$$typeof === Symbol.for('react.forward_ref') diff --git a/client/tests/environment/jsdom-native-abort.ts b/client/tests/environment/jsdom-native-abort.ts index 1a16aaa8b..4dd697118 100644 --- a/client/tests/environment/jsdom-native-abort.ts +++ b/client/tests/environment/jsdom-native-abort.ts @@ -20,13 +20,13 @@ * jsdom setup runs, so vitest copies jsdom's Storage across. */ -import { builtinEnvironments } from 'vitest/environments'; +import { builtinEnvironments } from 'vitest/runtime'; const jsdomEnv = builtinEnvironments.jsdom; export default { name: 'jsdom-native-abort', - transformMode: 'web' as const, + viteEnvironment: 'client' as const, async setup(global: typeof globalThis, options: Record) { // Capture native AbortController/AbortSignal BEFORE jsdom patches them diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx index 383ef9e4d..5a657cbad 100644 --- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx @@ -160,7 +160,7 @@ describe('RtStopRow', () => { const { container } = render() expect(screen.getByText('+25 min')).toBeInTheDocument() - expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(container.querySelector('.lucide-triangle-alert')).not.toBeNull() }) it('FE-MOB-RTROW-011: writes a range warning as the distance driven since the last fill', () => { @@ -247,7 +247,7 @@ describe('RtStopRow', () => { const { container } = render() expect(screen.getByText('+20 min')).toBeInTheDocument() - expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(container.querySelector('.lucide-triangle-alert')).not.toBeNull() // It used to fall through to the night's word. expect(screen.queryByText('roadtrip.warn.overnight')).toBeNull() }) @@ -380,7 +380,7 @@ describe('RtRideRow', () => { const mark = screen.getByRole('img', { name: 'roadtrip.ride.lateHintFlight:LH 2020,13:20,12:20,Hamburg Airport,21:59' }) expect(mark).toHaveTextContent('roadtrip.ride.missed') expect(within(mark).getByText('roadtrip.ride.reachedAt:21:59')).toBeInTheDocument() - expect(mark.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(mark.querySelector('.lucide-triangle-alert')).not.toBeNull() expect(screen.getByRole('button').className).toContain('border-[color:var(--m-st-pending)]') }) diff --git a/client/vitest.config.ts b/client/vitest.config.ts index 531097d32..05dca0458 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -21,8 +21,12 @@ export default defineConfig({ testTimeout: 15000, hookTimeout: 15000, pool: 'forks', - silent: false, - reporters: ['verbose'], + // Console output is kept for failing tests and dropped for passing ones: a + // green run used to print ~30k lines of MSW and act() warnings. `default` + // prints one line per file and the full detail only for failures; on CI the + // github-actions reporter adds inline annotations on the PR. + silent: 'passed-only', + reporters: process.env.CI ? ['default', 'github-actions'] : ['default'], coverage: { provider: 'v8', reporter: ['lcov', 'text'], diff --git a/package-lock.json b/package-lock.json index 573569006..069a8d3bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,235 +30,104 @@ "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", - "@fontsource/geist-sans": "^5.2.5", + "@fontsource/geist-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", "@fontsource/lora": "^5.3.0", "@fontsource/museomoderno": "^5.3.0", "@fontsource/playfair-display": "^5.3.0", - "@fontsource/poppins": "^5.2.7", + "@fontsource/poppins": "^5.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", - "@simplewebauthn/browser": "^13.1.2", + "@simplewebauthn/browser": "^13.3.0", "@trek/shared": "*", - "axios": "^1.6.7", - "dexie": "^4.4.2", + "axios": "^1.20.0", + "dexie": "^4.4.6", "drag-drop-touch": "^1.3.1", - "heic-to": "^1.4.2", + "heic-to": "^1.5.2", "iso-3166-2": "^1.0.0", "leaflet": "^1.9.4", - "lucide-react": "^0.344.0", - "mapbox-gl": "^3.22.0", + "lucide-react": "^0.577.0", + "mapbox-gl": "^3.31.0", "maplibre-gl": "5.24.0", - "marked": "^18.0.0", + "marked": "^18.0.13", "plyr": "^3.8.4", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-dropzone": "^14.4.1", "react-leaflet": "^5.0.0", "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "tz-lookup": "^6.1.25", - "zod": "^4.3.6", - "zustand": "^5.0.14" + "zod": "^4.6.5", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.60.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^6.10.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/leaflet": "^1.9.8", - "@types/node": "^25.9.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@types/leaflet": "^1.9.22", + "@types/node": "^25.9.8", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", - "autoprefixer": "^10.4.18", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", + "autoprefixer": "^10.6.1", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "fake-indexeddb": "^6.2.5", - "jsdom": "^29.0.1", - "msw": "^2.13.0", - "postcss": "^8.4.35", - "prettier": "^3.8.3", + "jsdom": "^29.1.1", + "msw": "^2.15.0", + "postcss": "^8.5.28", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "rollup-plugin-visualizer": "^7.0.1", + "prettier-plugin-tailwindcss": "^0.8.1", + "rollup-plugin-visualizer": "^7.1.1", "sharp": "^0.35.4", - "tailwindcss": "^3.4.1", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", - "vite": "8.1.0", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "8.3.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.11" } }, - "client/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "client/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "client/node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "client/node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "client/node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "client/node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "@rolldown/plugin-babel": { "optional": true }, - "@vitejs/devtools": { + "babel-plugin-react-compiler": { "optional": true }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "client/node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { + "oxc-transform-react": { "optional": true } } @@ -304,6 +173,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -320,6 +190,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -336,6 +207,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -345,6 +217,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, "license": "MIT" }, "node_modules/@aws-lite/client": { @@ -2369,10 +2242,71 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", "funding": [ { "type": "github", @@ -2389,9 +2323,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", "funding": [ { "type": "github", @@ -2412,9 +2346,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", - "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", "funding": [ { "type": "github", @@ -2427,8 +2361,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.1" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" }, "engines": { "node": ">=20.19.0" @@ -2461,9 +2395,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", - "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", "funding": [ { "type": "github", @@ -3071,9 +3005,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3097,9 +3031,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { @@ -3109,7 +3043,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -3138,9 +3072,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3244,9 +3178,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3293,9 +3227,9 @@ } }, "node_modules/@fontsource/geist-sans": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz", - "integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz", + "integrity": "sha512-AmG7cE1AvE4NHROPaOl1sN+a02cB5xy6pA/ZV76HXqWTCsJTV2SCBc9DTFHfJqnVOODm7HhXIYtUIb75NP5QXQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -3338,9 +3272,9 @@ } }, "node_modules/@fontsource/poppins": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz", - "integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.3.0.tgz", + "integrity": "sha512-cms1nM7U6SN8epIV1WMVOV8VsA645aSm3wHfzIQnkk188U341ThbJGSl2Sv54V29gnDbT2arMPfLzvVIIVCceQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -4688,6 +4622,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@levischuck/tiny-cbor": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", @@ -4839,6 +4780,269 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@mikro-orm/cli": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-7.2.1.tgz", + "integrity": "sha512-YNt8HKsb90awZqcionp1oZgL2ZLRyGl76x0lYdcfVkYkYY0zrNQs3ZZ76+6AS/FXSX5oEp6q94LCIiYwnr04jA==", + "license": "MIT", + "dependencies": { + "@mikro-orm/core": "7.2.1", + "mikro-orm": "7.2.1", + "yargs": "17.7.2" + }, + "bin": { + "mikro-orm": "cli.js" + }, + "engines": { + "node": ">= 22.17.0" + } + }, + "node_modules/@mikro-orm/cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@mikro-orm/cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@mikro-orm/cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@mikro-orm/cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/cli/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/core": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-7.2.1.tgz", + "integrity": "sha512-+gtJoScbin/C3wPItSZkj5vlMTq/LphBLIxHyi5brCbeA6VDcYuoMopiqoFUMQxdhXXrZlKNIHXGrhb8r3biVg==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "funding": { + "url": "https://github.com/sponsors/b4nan" + }, + "peerDependencies": { + "dataloader": "2.2.3" + }, + "peerDependenciesMeta": { + "dataloader": { + "optional": true + } + } + }, + "node_modules/@mikro-orm/entity-generator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/entity-generator/-/entity-generator-7.2.1.tgz", + "integrity": "sha512-SMzqZ9VsIAbbO4x4IEWgMmMoujKcUxJQMIFKbOPard0D+eNpy4wiTKnP44nLHLPJ8CtDqUPh7W9eC9K9JW3C9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/migrations": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/migrations/-/migrations-7.2.1.tgz", + "integrity": "sha512-sxGcB7na0AkcREsD5U3xP+Psb/nhQFkx7/eY1Nsqq9MBs67O5RoCL97bn3lmgq6GwKZFbktMuuOGozq42+GaNg==", + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/nestjs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-7.1.0.tgz", + "integrity": "sha512-S7UXOR4jYtl+lSl23GxQNoWaI3naf5WGUaWMjqZ1o+xcp+fha8une9Yu3qEizZNA+iulE4qA+yaB6sKnA67fbw==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^7.0.0 || ^7.0.0-dev.0", + "@nestjs/common": "^11.0.5 || ^12.0.0", + "@nestjs/core": "^11.0.5 || ^12.0.0", + "reflect-metadata": "^0.1.0 || ^0.2.0" + } + }, + "node_modules/@mikro-orm/postgresql": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-7.2.1.tgz", + "integrity": "sha512-PQ1qe4Bj/hQSHYJjR02l06FfEDwniAvncduwZSCLSKuaCUddHc5AN2Utj3yo4xRIVRY8pOa7DFitEQPBozTMJA==", + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1", + "kysely": "0.29.6", + "pg": "8.23.0", + "pg-cursor": "2.22.0", + "postgres-array": "3.0.4", + "postgres-date": "2.1.0", + "postgres-interval": "4.1.0" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/seeder": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/seeder/-/seeder-7.2.1.tgz", + "integrity": "sha512-aZtpFXNW8MnP4/e351SMLuVuarHK3g713puMjRcHmLN38j+VLVL/uSoDAk+/KTP2neuNOe+5I8ioC3fTJI/qDQ==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/sql": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/sql/-/sql-7.2.1.tgz", + "integrity": "sha512-3M9dQeUDLlvAT5VJr/KBGAk9C7xez+psVCnvNkAO4hRhwx2Wm7N1HkuyP4Bwj0MjkHVB6LYvhTw/V6pYsVM8lA==", + "license": "MIT", + "dependencies": { + "kysely": "0.29.6" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/sqlite": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-7.2.1.tgz", + "integrity": "sha512-+iAPGORAqLl+TcBF+JSLOfRq+PwTQG1NLts+AvN9D8C2czlbDpyjyeaAFSR+5wJlMEZ4wv2rdzt4jSS5T7x3XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1", + "better-sqlite3": "13.0.3", + "kysely": "0.29.6" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -5400,9 +5604,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", - "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.5.tgz", + "integrity": "sha512-x3LYEZnbGZMIeu9m60ws2+ZWzFAok9zGwjXZjcmdAeCLw6BYEjp9lreimuEpDIsLXK9bi6iYcJsYQGat9wq/rQ==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -5458,9 +5662,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", - "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.5.tgz", + "integrity": "sha512-ZgF8aitL7h8VPyVQ+vlUDrSVzbR7AojCv9Je9JqJVfHw3TtD3OOQ82RsCKrxfx09xpoveRr7UBHPA/d24bu2lA==", "license": "MIT", "dependencies": { "fast-safe-stringify": "2.1.1", @@ -5517,14 +5721,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", - "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz", + "integrity": "sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.1.1", + "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -5830,13 +6034,13 @@ } }, "node_modules/@nestjs/platform-ws": { - "version": "11.1.29", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-11.1.29.tgz", - "integrity": "sha512-AYs3VZ5PxcDLiyGfvxsAS8DP9d7q+jxCKoWfMdl9RQLz69WAzSX2x+FgWNupTHt19zf8rmFXt2hvGd2tBUROAQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-11.2.5.tgz", + "integrity": "sha512-RttHEJN03qF7PppKfBtg66125QUjt9LhdWSpsQY8BnRbXLYVTR1g3FyCL27Q6UAd5nWS8vOkghRcRiDLjFX+fg==", "license": "MIT", "dependencies": { "tslib": "2.8.1", - "ws": "8.21.1" + "ws": "8.21.2" }, "funding": { "type": "opencollective", @@ -5848,6 +6052,27 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", @@ -5917,9 +6142,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz", - "integrity": "sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.5.tgz", + "integrity": "sha512-6PEjtGlqwpXDUDsiuSmE7EoRGHs27YAmHctgLMXfeb2oyWQsg5a/8+vQTnY/oF4c6wSRPM9x5OPtQ/JCuVNg9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5945,9 +6170,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.1.29", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.29.tgz", - "integrity": "sha512-Mg0sNK2k+PpamcJpDy9ZP24ZvM08xB6AZs061Kb6dhTesGxr6zrxlbZ7+40r5xwuxD/PN8LM2tSRKf5ojbFNDQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.2.5.tgz", + "integrity": "sha512-KSTpTWKFSrBYxxXyclDg3ir9EzPWUU8iuKK4VFaVno2L+kwZcZzsKmnXrcMnmx8Sl2glgqXQg26Esu8hbo6a0Q==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -5981,9 +6206,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "funding": [ { "type": "github", @@ -6307,19 +6532,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@quansync/fs": { @@ -6346,6 +6571,23 @@ "react-dom": "^19.0.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", @@ -7112,9 +7354,9 @@ "license": "MIT" }, "node_modules/@simplewebauthn/server": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", - "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", "license": "MIT", "dependencies": { "@hexagon/base64": "^1.1.27", @@ -7225,15 +7467,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", + "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" + "@swc/types": "^0.1.28" }, "engines": { "node": ">=10" @@ -7243,18 +7485,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.41", - "@swc/core-darwin-x64": "1.15.41", - "@swc/core-linux-arm-gnueabihf": "1.15.41", - "@swc/core-linux-arm64-gnu": "1.15.41", - "@swc/core-linux-arm64-musl": "1.15.41", - "@swc/core-linux-ppc64-gnu": "1.15.41", - "@swc/core-linux-s390x-gnu": "1.15.41", - "@swc/core-linux-x64-gnu": "1.15.41", - "@swc/core-linux-x64-musl": "1.15.41", - "@swc/core-win32-arm64-msvc": "1.15.41", - "@swc/core-win32-ia32-msvc": "1.15.41", - "@swc/core-win32-x64-msvc": "1.15.41" + "@swc/core-darwin-arm64": "1.16.2", + "@swc/core-darwin-x64": "1.16.2", + "@swc/core-linux-arm-gnueabihf": "1.16.2", + "@swc/core-linux-arm64-gnu": "1.16.2", + "@swc/core-linux-arm64-musl": "1.16.2", + "@swc/core-linux-ppc64-gnu": "1.16.2", + "@swc/core-linux-s390x-gnu": "1.16.2", + "@swc/core-linux-x64-gnu": "1.16.2", + "@swc/core-linux-x64-musl": "1.16.2", + "@swc/core-win32-arm64-msvc": "1.16.2", + "@swc/core-win32-ia32-msvc": "1.16.2", + "@swc/core-win32-x64-msvc": "1.16.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -7266,9 +7508,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", "cpu": [ "arm64" ], @@ -7283,9 +7525,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", "cpu": [ "x64" ], @@ -7300,9 +7542,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", "cpu": [ "arm" ], @@ -7317,13 +7559,16 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7334,13 +7579,16 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7351,13 +7599,16 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", + "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7368,13 +7619,16 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", + "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7385,13 +7639,16 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7402,13 +7659,16 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7419,9 +7679,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", "cpu": [ "arm64" ], @@ -7436,9 +7696,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", "cpu": [ "ia32" ], @@ -7453,9 +7713,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", "cpu": [ "x64" ], @@ -7477,9 +7737,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", - "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7508,9 +7768,10 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz", + "integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==", + "deprecated": "Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0.", "dev": true, "license": "MIT", "dependencies": { @@ -7522,9 +7783,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -7535,9 +7799,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -7563,9 +7827,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -7870,13 +8134,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7896,9 +8153,9 @@ } }, "node_modules/@types/leaflet": { - "version": "1.9.21", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", - "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -7940,9 +8197,9 @@ "license": "MIT" }, "node_modules/@types/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", "dev": true, "license": "MIT", "dependencies": { @@ -7950,9 +8207,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.8.tgz", + "integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==", "dev": true, "license": "MIT", "dependencies": { @@ -7960,9 +8217,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", "dev": true, "license": "MIT", "dependencies": { @@ -7994,22 +8251,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/readdir-glob": { @@ -8030,9 +8287,9 @@ "license": "MIT" }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, @@ -8151,17 +8408,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -8174,15 +8431,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -8190,16 +8447,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "engines": { @@ -8215,14 +8472,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "engines": { @@ -8237,14 +8494,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8255,9 +8512,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { @@ -8272,15 +8529,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -8297,9 +8554,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", "engines": { @@ -8311,16 +8568,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8349,9 +8606,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8362,13 +8619,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -8378,16 +8635,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8402,13 +8659,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8425,32 +8682,6 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, "node_modules/@vitest/coverage-istanbul": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.11.tgz", @@ -8620,6 +8851,385 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yuku-codegen/binding-android-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-android-arm64/-/binding-android-arm64-0.10.2.tgz", + "integrity": "sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-codegen/binding-darwin-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.10.2.tgz", + "integrity": "sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-darwin-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.10.2.tgz", + "integrity": "sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-freebsd-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.10.2.tgz", + "integrity": "sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.10.2.tgz", + "integrity": "sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.10.2.tgz", + "integrity": "sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.10.2.tgz", + "integrity": "sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.10.2.tgz", + "integrity": "sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.10.2.tgz", + "integrity": "sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.10.2.tgz", + "integrity": "sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-win32-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.10.2.tgz", + "integrity": "sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-codegen/binding-win32-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.10.2.tgz", + "integrity": "sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-android-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-android-arm64/-/binding-android-arm64-0.10.2.tgz", + "integrity": "sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-parser/binding-darwin-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.10.2.tgz", + "integrity": "sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-darwin-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.10.2.tgz", + "integrity": "sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-freebsd-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.10.2.tgz", + "integrity": "sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.10.2.tgz", + "integrity": "sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.10.2.tgz", + "integrity": "sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.10.2.tgz", + "integrity": "sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.10.2.tgz", + "integrity": "sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.10.2.tgz", + "integrity": "sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.10.2.tgz", + "integrity": "sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-win32-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.10.2.tgz", + "integrity": "sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-win32-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.10.2.tgz", + "integrity": "sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.10.2.tgz", + "integrity": "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8727,16 +9337,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", - "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/any-base": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", @@ -8916,74 +9516,6 @@ "node": ">=12" } }, - "node_modules/ast-kit": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz", - "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "estree-walker": "^3.0.3", - "pathe": "^2.0.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", @@ -9045,9 +9577,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -9065,8 +9597,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -9113,13 +9645,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -9299,9 +9831,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.22", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", - "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9317,10 +9849,23 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -9339,16 +9884,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/birpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", - "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -9362,9 +9897,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -9375,7 +9910,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -9423,9 +9958,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9445,9 +9980,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -9465,11 +10000,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -9545,6 +10080,30 @@ "node": ">=20.19.0" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -9877,14 +10436,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -9892,6 +10452,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -10462,9 +11026,9 @@ } }, "node_modules/dexie": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", - "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.6.tgz", + "integrity": "sha512-hJP/BO6mjB+tX6hToIO1kmxYLNmun90wYbfcoAoLpKEyDYal/k33dg0TAfoUe2TDsbbLoVIzljJ3BN2UbR5EOg==", "license": "Apache-2.0" }, "node_modules/dezalgo": { @@ -10507,9 +11071,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10666,9 +11230,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", "dev": true, "license": "ISC" }, @@ -10915,7 +11479,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10941,9 +11504,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -10953,9 +11516,9 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -10970,14 +11533,14 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -11000,13 +11563,13 @@ } }, "node_modules/eslint-config-flat-gitignore": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.3.0.tgz", - "integrity": "sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.4.0.tgz", + "integrity": "sha512-JhYC+V7qEDiCDsbJcVP8fxNc62fk4+i91YLP/YvmVHlkaQ8Xo99olYN8MO5COdxl7onGlsrfZVONe5NYsZP1UA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint/compat": "^2.0.3" + "@eslint/compat": "^2.1.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -11083,9 +11646,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -11164,6 +11727,28 @@ "node": "20 || >=22" } }, + "node_modules/eslint/node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/eslint/node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -11345,9 +11930,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -11369,9 +11954,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -11574,9 +12159,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.2.tgz", - "integrity": "sha512-DYPkXnVSJHAGAkSBeVYhEo/seIpz2SLr9OQcX7m6lXaX3gvoB+DCKzFdZIEhZGI3I1DUhObBAUOT/v2xfoXz/w==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", "funding": [ { "type": "github", @@ -11585,17 +12170,32 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.4.0", - "xml-naming": "^0.1.0" + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -11876,9 +12476,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -12056,22 +12656,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "5.0.0-beta.5", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", - "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gifwrap": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", @@ -12270,6 +12854,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -12355,9 +12952,9 @@ "license": "LGPL-3.0" }, "node_modules/helmet": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", - "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -12399,6 +12996,13 @@ "dev": true, "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -12621,9 +13225,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -13186,9 +13790,9 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", "funding": [ { "type": "github", @@ -13278,16 +13882,119 @@ "integrity": "sha512-xLAazfKZzwlsg/Zz/GQGQk3jJez5/2ORrjD3TjSuqz/arMht/xTK49c0GOE3afO/gEd9tHtBVVlfBla01unUng==" }, "node_modules/isomorphic-dompurify": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.17.0.tgz", - "integrity": "sha512-++PY22SYWZv/kTWOr5WQ8rNznkIKj6I/SDUVZnXXM67EhRO7ScVLDdZYNxNgEsPAOA5sfpGBcqnnbLx8WJHO1g==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.23.0.tgz", + "integrity": "sha512-RuYFgdNApoTlR3QxVUyWdS/1ATpLEWEVg3H2tMImbfr6HkYPnuW1sdEtW4PRGlFhp04VbQQjcjXnucHYpXT44A==", + "deprecated": "Raised the minimum Node.js version (breaking) without a major bump. Use 4.x for the same code with correct semver, or pin 3.19.0 for Node < 22.22.2.", "license": "MIT", "dependencies": { - "dompurify": "^3.4.10", - "jsdom": "^29.1.1" + "dompurify": "^3.4.12", + "jsdom": "^30.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", + "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.2.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", + "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.1.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/isomorphic-dompurify/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/isomorphic-dompurify/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/istanbul-lib-coverage": { @@ -13488,6 +14195,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -13528,6 +14236,7 @@ "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13671,6 +14380,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -13759,9 +14477,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -13775,23 +14493,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -13810,9 +14528,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -13831,9 +14549,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -13852,9 +14570,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -13873,9 +14591,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -13894,13 +14612,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13915,13 +14636,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13936,13 +14660,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13957,13 +14684,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13978,9 +14708,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -13999,9 +14729,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -14159,13 +14889,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14199,12 +14922,12 @@ } }, "node_modules/lucide-react": { - "version": "0.344.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", - "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/luxon": { @@ -14266,15 +14989,14 @@ } }, "node_modules/mapbox-gl": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz", - "integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==", + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.31.0.tgz", + "integrity": "sha512-7i25NyCPW5jnqsqJq3irgBBEoVTJgfZqTJJS446Q3ZuxyAb5Om75UIcxYIo38oz9eDswnBXr0riz+M/C8V7g3Q==", "license": "SEE LICENSE IN LICENSE.txt", "workspaces": [ "src/style-spec", "plugins/mapbox-gl-pmtiles-provider", - "test/build/vite", - "test/build/webpack", + "test/bundlers/*", "test/build/typings" ] }, @@ -14323,9 +15045,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -15259,6 +15981,15 @@ "node": ">=8.6" } }, + "node_modules/mikro-orm": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-7.2.1.tgz", + "integrity": "sha512-IUUYgO5T+S67sw1Xp5r3vL0JeYnkampUIRJaHG6AzTcjujRbyRUOEc6NVAIsojKdXq9WbWRU0hI6ivUv9qSDLA==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + } + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -15344,9 +16075,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.14.6", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", - "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15580,9 +16311,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -15650,9 +16381,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", "dev": true, "license": "MIT", "engines": { @@ -15660,9 +16391,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.13.tgz", - "integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==", + "version": "10.0.14", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz", + "integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==", "license": "MIT-0", "engines": { "node": ">=20.0.0" @@ -15830,9 +16561,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -16116,9 +16847,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "funding": [ { "type": "github", @@ -16234,6 +16965,134 @@ "@napi-rs/canvas": "^0.1.80" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.22.0.tgz", + "integrity": "sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg-types/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16305,50 +17164,32 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/plyr": { @@ -16384,9 +17225,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -16404,7 +17245,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16546,6 +17387,42 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-4.1.0.tgz", + "integrity": "sha512-nJLACcOHi2iyRuMJeW6CuIA0Dc0i1YEpRr26XDyHAfxPjYUJOFxwZjmOc5oFFctTTh/OlRAMlnlXEJvr170fJg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/potpack": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", @@ -16576,9 +17453,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", "bin": { @@ -16622,9 +17499,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.0.tgz", - "integrity": "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", + "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", "dev": true, "license": "MIT", "engines": { @@ -16849,6 +17726,26 @@ "node": ">=16.0.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -17050,12 +17947,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -17155,30 +18053,30 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.3.0" } }, "node_modules/react-dom/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/react-dropzone": { @@ -17263,6 +18161,47 @@ "react": ">=18" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react-router/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -17665,118 +18604,6 @@ "@rolldown/binding-win32-x64-msvc": "1.1.2" } }, - "node_modules/rolldown-plugin-dts": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.26.0.tgz", - "integrity": "sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0", - "@babel/parser": "^8.0.0", - "ast-kit": "^3.0.0", - "birpc": "^4.0.0", - "dts-resolver": "^3.0.0", - "get-tsconfig": "5.0.0-beta.5", - "obug": "^2.1.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@ts-macro/tsc": "^0.3.6", - "@typescript/native-preview": ">=7.0.0-dev.20260325.1", - "rolldown": "^1.0.0", - "typescript": "^5.0.0 || ^6.0.0", - "vue-tsc": "~3.2.0 || ~3.3.0" - }, - "peerDependenciesMeta": { - "@ts-macro/tsc": { - "optional": true - }, - "@typescript/native-preview": { - "optional": true - }, - "typescript": { - "optional": true - }, - "vue-tsc": { - "optional": true - } - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -17823,16 +18650,16 @@ } }, "node_modules/rollup-plugin-visualizer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", - "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.1.1.tgz", + "integrity": "sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA==", "dev": true, "license": "MIT", "dependencies": { "open": "^11.0.0", - "picomatch": "^4.0.2", - "source-map": "^0.7.4", - "yargs": "^18.0.0" + "picomatch": "^4.0.5", + "source-map": "^0.8.0", + "yargs": "^18.1.0" }, "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" @@ -17866,14 +18693,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/rollup-plugin-visualizer/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/rollup-plugin-visualizer/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, "engines": { - "node": ">= 12" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/router": { @@ -18405,17 +19257,13 @@ } }, "node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -18448,35 +19296,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/source-map/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/source-map/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -18487,6 +19306,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -18743,9 +19571,9 @@ } }, "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", "funding": [ { "type": "github", @@ -18912,6 +19740,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, "license": "MIT" }, "node_modules/synckit": { @@ -19124,9 +19953,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -19286,9 +20115,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -19373,92 +20202,6 @@ "node": ">=6" } }, - "node_modules/tsdown": { - "version": "0.22.3", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.3.tgz", - "integrity": "sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansis": "^4.3.1", - "cac": "^7.0.0", - "defu": "^6.1.7", - "empathic": "^2.0.1", - "hookable": "^6.1.1", - "import-without-cache": "^0.4.0", - "obug": "^2.1.3", - "picomatch": "^4.0.4", - "rolldown": "~1.1.1", - "rolldown-plugin-dts": "^0.26.0", - "semver": "^7.8.4", - "tinyexec": "^1.2.4", - "tinyglobby": "^0.2.17", - "tree-kill": "^1.2.2", - "unconfig-core": "^7.5.0" - }, - "bin": { - "tsdown": "dist/run.mjs" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.22.3", - "@tsdown/exe": "0.22.3", - "@vitejs/devtools": "*", - "publint": "^0.3.8", - "tsx": "*", - "typescript": "^5.0.0 || ^6.0.0", - "unplugin-unused": "^0.5.0", - "unrun": "*" - }, - "peerDependenciesMeta": { - "@arethetypeswrong/core": { - "optional": true - }, - "@tsdown/css": { - "optional": true - }, - "@tsdown/exe": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "publint": { - "optional": true - }, - "tsx": { - "optional": true - }, - "typescript": { - "optional": true - }, - "unplugin-unused": { - "optional": true - }, - "unrun": { - "optional": true - } - } - }, - "node_modules/tsdown/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -19466,9 +20209,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -19641,16 +20384,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19913,40 +20656,83 @@ } }, "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz", + "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=18.12.0" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/unplugin-swc": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.9.tgz", - "integrity": "sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.6.0.tgz", + "integrity": "sha512-aOdzxAwJvz1GeQ8vwSNA2N8AreHYBrpwxqnwzxmB7LCdeZiXOBDj0rgjZod+lMxFsJGm8k6lF/1iKF/9wacLbw==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.3.0", + "@rollup/pluginutils": "^5.4.0", "load-tsconfig": "^0.2.5", - "unplugin": "^2.3.11" + "unplugin": "^3.3.0" }, "peerDependencies": { "@swc/core": "^1.2.108" } }, "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -19994,14 +20780,14 @@ } }, "node_modules/unzipper": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", - "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "license": "MIT", "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fs-extra": "^11.2.0", + "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } @@ -20018,9 +20804,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", - "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -20089,9 +20875,9 @@ } }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -20110,6 +20896,19 @@ "node": ">= 0.8" } }, + "node_modules/verkit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.1.tgz", + "integrity": "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -20139,16 +20938,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -20165,7 +20964,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -20247,54 +21046,20 @@ } } }, - "node_modules/vite/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "cpu": [ "arm64" ], @@ -20309,9 +21074,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "cpu": [ "arm64" ], @@ -20326,9 +21091,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "cpu": [ "x64" ], @@ -20343,9 +21108,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "cpu": [ "x64" ], @@ -20360,9 +21125,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "cpu": [ "arm" ], @@ -20377,13 +21142,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20394,13 +21162,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -20411,13 +21182,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20428,13 +21202,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20445,13 +21222,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20462,13 +21242,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -20479,9 +21262,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "cpu": [ "arm64" ], @@ -20495,29 +21278,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "cpu": [ "arm64" ], @@ -20532,9 +21296,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "cpu": [ "x64" ], @@ -20549,9 +21313,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -20562,13 +21326,13 @@ } }, "node_modules/vite/node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -20578,21 +21342,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, "node_modules/vitest": { @@ -21211,9 +21975,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -21306,11 +22070,19 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -21364,6 +22136,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yuku-ast": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.10.2.tgz", + "integrity": "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + } + }, + "node_modules/yuku-codegen": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.10.2.tgz", + "integrity": "sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-codegen/binding-android-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-x64": "0.10.2", + "@yuku-codegen/binding-freebsd-x64": "0.10.2", + "@yuku-codegen/binding-linux-arm-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm-musl": "0.10.2", + "@yuku-codegen/binding-linux-arm64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm64-musl": "0.10.2", + "@yuku-codegen/binding-linux-x64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-x64-musl": "0.10.2", + "@yuku-codegen/binding-win32-arm64": "0.10.2", + "@yuku-codegen/binding-win32-x64": "0.10.2" + } + }, + "node_modules/yuku-parser": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.10.2.tgz", + "integrity": "sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2", + "yuku-ast": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-parser/binding-android-arm64": "0.10.2", + "@yuku-parser/binding-darwin-arm64": "0.10.2", + "@yuku-parser/binding-darwin-x64": "0.10.2", + "@yuku-parser/binding-freebsd-x64": "0.10.2", + "@yuku-parser/binding-linux-arm-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm-musl": "0.10.2", + "@yuku-parser/binding-linux-arm64-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm64-musl": "0.10.2", + "@yuku-parser/binding-linux-x64-gnu": "0.10.2", + "@yuku-parser/binding-linux-x64-musl": "0.10.2", + "@yuku-parser/binding-win32-arm64": "0.10.2", + "@yuku-parser/binding-win32-x64": "0.10.2" + } + }, "node_modules/zip-stream": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", @@ -21379,9 +22210,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -21409,6 +22240,35 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -21425,32 +22285,39 @@ "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", + "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/migrations": "^7.2.1", + "@mikro-orm/nestjs": "^7.1.0", + "@mikro-orm/postgresql": "^7.2.1", + "@mikro-orm/seeder": "^7.2.1", + "@mikro-orm/sql": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", - "@nestjs/common": "^11.1.24", + "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.1.24", - "@nestjs/platform-express": "^11.1.24", - "@nestjs/platform-ws": "^11.1.29", + "@nestjs/core": "^11.2.5", + "@nestjs/platform-express": "^11.2.5", + "@nestjs/platform-ws": "^11.2.5", "@nestjs/schedule": "^6.1.3", - "@nestjs/swagger": "^11.4.5", - "@nestjs/websockets": "^11.1.29", - "@simplewebauthn/server": "^13.3.2", + "@nestjs/swagger": "^11.4.7", + "@nestjs/websockets": "^11.2.5", + "@simplewebauthn/server": "^13.3.3", "@trek/shared": "*", - "archiver": "^6.0.1", + "archiver": "^6.0.2", "bcryptjs": "^2.4.3", "better-sqlite3": "^13.0.3", - "compression": "^1.8.0", + "compression": "^1.8.2", "cookie-parser": "^1.4.7", - "cors": "^2.8.5", + "cors": "^2.8.6", "cron": "^4.4.0", - "dotenv": "^16.4.1", + "dotenv": "^16.6.1", "exifr": "7.1.3", - "express": "^4.18.3", - "fast-xml-parser": "^5.5.10", - "helmet": "^8.1.0", + "express": "^4.22.3", + "fast-xml-parser": "^5.11.1", + "helmet": "^8.3.0", "jimp": "^1.6.1", "json5": "^2.2.3", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", + "kysely": "^0.29.6", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -21458,52 +22325,55 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "semver": "^7.7.4", + "semver": "^7.8.5", "tsconfig-paths": "^4.2.0", - "tsx": "^4.21.0", - "typescript": "^6.0.2", + "tsx": "^4.23.15", + "typescript": "^6.0.3", "tz-lookup": "^6.1.25", "undici": "^7.30.0", - "unzipper": "^0.12.3", - "uuid": "^14.0.0", - "ws": "^8.21.0", - "zod": "^4.3.6" + "unzipper": "^0.12.5", + "uuid": "^14.0.2", + "ws": "^8.21.3", + "zod": "^4.6.5" }, "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@nestjs/testing": "^11.1.24", - "@swc/core": "^1.15.40", + "@mikro-orm/core": "^7.2.1", + "@mikro-orm/entity-generator": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", + "@nestjs/testing": "^11.2.5", + "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", - "@types/compression": "^1.8.0", + "@types/compression": "^1.8.1", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^25.5.0", - "@types/nodemailer": "^8.0.1", - "@types/qrcode": "^1.5.5", - "@types/semver": "^7.7.1", + "@types/multer": "^2.2.0", + "@types/node": "^25.9.8", + "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", + "@types/semver": "^7.8.0", "@types/supertest": "^6.0.3", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", "@vitest/coverage-istanbul": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.18.0", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "nodemon": "^3.1.0", - "prettier": "^3.8.3", + "eslint": "^9.39.5", + "eslint-config-flat-gitignore": "^2.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "nodemon": "^3.1.14", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", "supertest": "^7.2.2", - "typescript-eslint": "^8.58.2", - "unplugin-swc": "^1.5.9", + "typescript-eslint": "^8.70.0", + "unplugin-swc": "^1.6.0", "vitest": "^4.1.11" } }, @@ -21605,19 +22475,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "server/node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=22" - } - }, "server/node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -21647,9 +22504,10 @@ } }, "server/node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -21658,8 +22516,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -21737,9 +22595,9 @@ } }, "server/node_modules/eslint/node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -21804,39 +22662,484 @@ "name": "@trek/shared", "version": "4.3.3", "dependencies": { - "isomorphic-dompurify": "^3.15.0", - "zod": "^4.3.6" + "isomorphic-dompurify": "^3.23.0", + "zod": "^4.6.5" }, "devDependencies": { "@eslint/js": "^10.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^10.3.0", - "eslint-config-flat-gitignore": "^2.3.0", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "prettier": "3.8.3", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "tsdown": "^0.22.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", + "tsdown": "^0.23.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", "vitest": "^4.1.11" } }, - "shared/node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "shared/node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "shared/node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/get-tsconfig": { + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=20.20.0" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "shared/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "shared/node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "shared/node_modules/tsdown": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.0.tgz", + "integrity": "sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^7.0.0", + "defu": "^6.1.7", + "empathic": "^2.0.1", + "hookable": "^6.1.1", + "import-without-cache": "^0.4.0", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "rolldown": "~1.2.7", + "rolldown-plugin-dts": "^0.28.5", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "verkit": "^0.4.0" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.23.0", + "@tsdown/exe": "0.23.0", + "@vitejs/devtools": "*", + "publint": "^0.3.8", + "tsx": "*", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + } + } + }, + "shared/node_modules/tsdown/node_modules/rolldown-plugin-dts": { + "version": "0.28.6", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.28.6.tgz", + "integrity": "sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.10.1", + "yuku-codegen": "^0.10.1", + "yuku-parser": "^0.10.1" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@typescript/native-preview": "*", + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", + "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", + "vue-tsc": "~3.2.0 || ~3.3.0" + }, + "peerDependenciesMeta": { + "@typescript/native-preview": { + "optional": true + }, + "@volar/typescript": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "shared/node_modules/tsdown/node_modules/rolldown-plugin-dts/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" } } } diff --git a/package.json b/package.json index 4eebb0a01..f9db02cf2 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ }, "comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.", "overrides": { - "react": "19.2.6", - "react-dom": "19.2.6", + "react": "19.3.0", + "react-dom": "19.3.0", "multer": "^2.3.0" }, "comment:optionalDependencies": "The lockfile is generated on glibc, so the musl builds the Alpine image needs never land in it on their own. Keep the @img/* pins on the same version as client's sharp — a mismatch nests the binary away from libvips and the image build dies in generate-icons.", diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index bd29bd7d3..077381960 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -9,8 +9,8 @@ "version": "1.8.0", "license": "MIT", "dependencies": { - "@clack/prompts": "^1.7.0", - "semver": "^7.7.4", + "@clack/prompts": "^1.8.1", + "semver": "^7.8.5", "update-notifier": "^7.3.1" }, "bin": { @@ -19,12 +19,12 @@ "trek-plugin-sdk": "dist/cli/trek-plugin.js" }, "devDependencies": { - "@types/node": "^22.20.0", - "@types/semver": "^7.7.1", + "@types/node": "^22.20.4", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.11" }, "engines": { "node": ">=18" @@ -91,9 +91,9 @@ } }, "node_modules/@clack/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", - "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -104,12 +104,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", - "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.4.3", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -517,9 +517,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -527,9 +527,9 @@ } }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index defd5b17a..a7a8e66e3 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -63,8 +63,8 @@ "dist" ], "dependencies": { - "@clack/prompts": "^1.7.0", - "semver": "^7.7.4", + "@clack/prompts": "^1.8.1", + "semver": "^7.8.5", "update-notifier": "^7.3.1" }, "scripts": { @@ -75,12 +75,12 @@ "prepublishOnly": "npm run build && npm test" }, "devDependencies": { - "@types/node": "^22.20.0", - "@types/semver": "^7.7.1", + "@types/node": "^22.20.4", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.11" }, "keywords": [ "trek", diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 885145d3b..32139bb5f 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -50,12 +50,12 @@ export const spinner = (): SpinnerResult => clackSpinner({ output: OUT }); // ── cancellation ───────────────────────────────────────────────────────────── /** Clack returns a symbol when the user hits Ctrl+C — turn that into a clean exit. */ -export function orCancel(value: T | symbol): T { +export function orCancel(value: T): Exclude { if (isCancel(value)) { clackCancel('Cancelled.', { output: OUT }); process.exit(0); } - return value as T; + return value as Exclude; } // ── prompts (cancel-checked, stderr-rendered) ──────────────────────────────── diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e582a1705..96148752b 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -16,6 +16,7 @@ npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/ npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sdk/ + shared/ npm run check:plugin-facts # CI gate — fails if the generated copies drifted node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts +npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any) ``` Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. @@ -32,14 +33,14 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator** — it bypasses that gate. - **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. - **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. -- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on; `database.ts` runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness). +- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only opens the connection and applies pragmas — it does not build a schema. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. `db/orm-driver.ts` binds the ORM to that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. Every read and write goes through a repository in `db/repositories/` (each extends the shared `TrekRepository` base in `db/repositories/_shared/`, which validates the request context before delegating to MikroORM) and, for multi-statement writes, `UnitOfWork.transactional(...)` (`nest/database/unit-of-work.ts`, provided by the global `OrmModule`) — there is no `DatabaseService`/`DatabaseModule` to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with `withRequestContext()` (`nest/database/request-context.ts`), since only the pathless `mikroOrmRequestContext` middleware `buildApp()` mounts (with `@mikro-orm/nestjs`'s own `registerRequestContext` turned off in `app.module.ts`, UPLOADS-P16) forks an `EntityManager` per HTTP request. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker, opened from a snapshot `tests/global-setup.ts` migrates once per run; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness). ## Rules for new code - **Thin controller → injectable provider.** Never resurrect a plain function-module service; don't grow god services — split a domain into several modules when it outgrows one class (trips and auth already are). -- **Inject, don't reach for module globals.** No new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Modules must be importable without side effects. -- **Transactions are not optional.** Multi-statement writes go in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT`. Bind values with `?`; identifiers only via a literal allow-list. -- **Migrations are append-only.** Identity is positional (array index == `schema_version`) — never reorder, insert mid-list, or delete; no env interpolation in SQL. +- **Inject, don't reach for module globals.** No imports of the global `db` proxy from `nest/` layers at all any more — the last production importer converted onto a repository, and the export now survives only so ~190 test files' `vi.mock('…/db/database', …)` factories keep typechecking; no new code depending on the `ws` singleton or event-sink globals. There is no `DatabaseService` to inject either — take the repository(ies) you need plus `UnitOfWork` for transactions. Modules must be importable without side effects. +- **Transactions are not optional.** Multi-statement writes go in `await this.uow.transactional(async () => …)` (`UnitOfWork` from `nest/database`, provided by the global `OrmModule`); never hand-roll `BEGIN`/`COMMIT` and never call `db.transaction()` (the Phase 1 async sweep removes it). Every DB-touching method is `async`; `@typescript-eslint/no-floating-promises` and `no-misused-promises` are errors, so an unawaited call or a `Promise` in a condition fails lint. Bind values with `?`; identifiers only via a literal allow-list. +- **Migrations are append-only.** A migration is identified by its class name in `mikro_orm_migrations`, and files sort by the timestamp in that name — never rename or delete one that has shipped, and never edit its body; add a new one. No env interpolation in SQL. (The old positional `schema_version` array and the hand-kept `db/migrations.ts` test builder are both gone; the numbered migrations are the only schema history, for tests too.) - **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip. - **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. - **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write. diff --git a/server/eslint-rules/fixtures/file.ts b/server/eslint-rules/fixtures/file.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/server/eslint-rules/fixtures/file.ts @@ -0,0 +1 @@ +export {}; diff --git a/server/eslint-rules/fixtures/strict.ts b/server/eslint-rules/fixtures/strict.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/server/eslint-rules/fixtures/strict.ts @@ -0,0 +1 @@ +export {}; diff --git a/server/eslint-rules/fixtures/tsconfig.json b/server/eslint-rules/fixtures/tsconfig.json new file mode 100644 index 000000000..023f92807 --- /dev/null +++ b/server/eslint-rules/fixtures/tsconfig.json @@ -0,0 +1,16 @@ +{ + // Fixture project for the RuleTester in tests/unit/eslint-rules/. It exists so + // typescript-eslint can build a real program for the virtual `file.ts` the + // tester feeds it. Kept outside src/ and tests/ on purpose: files here must not + // reach `tsc --noEmit`, `tsc -p tsconfig.tests.json` or the type-aware lint + // blocks, since the invalid cases are deliberately broken code. + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": false, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["file.ts"] +} diff --git a/server/eslint-rules/fixtures/tsconfig.strict.json b/server/eslint-rules/fixtures/tsconfig.strict.json new file mode 100644 index 000000000..48ce17e3c --- /dev/null +++ b/server/eslint-rules/fixtures/tsconfig.strict.json @@ -0,0 +1,15 @@ +{ + // Second fixture project, identical but with strictNullChecks ON. The server + // compiles with `strict: false`, where `Promise | null` collapses to + // `Promise` and the rule's nullable exemptions are unreachable — this one + // exists so those branches (`p ?? x`, `p === null`) are actually exercised. + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["strict.ts"] +} diff --git a/server/eslint-rules/no-promise-as-value.mjs b/server/eslint-rules/no-promise-as-value.mjs new file mode 100644 index 000000000..818928e72 --- /dev/null +++ b/server/eslint-rules/no-promise-as-value.mjs @@ -0,0 +1,344 @@ +/** + * trek/no-promise-as-value — the blind spot the compiler cannot see. + * + * Phase 1 of the ORM migration turns hundreds of DB-touching methods `async`. + * TypeScript catches a missed `await` only when the promise is used in a way it + * can type-check: `promise.foo` (TS2339), a floating statement + * (no-floating-promises), a condition (no-misused-promises). It catches NOTHING + * when the promise is used as a plain value, because this project compiles with + * `strict: false` / `noImplicitAny: false`: + * + * settings['metric'] // element access on a promise -> `any` + * if (svc.get(id) === 'x') // always false, no error + * `${svc.count(id)}` // "[object Promise]" + * Object.keys(svc.all()) // [] + * JSON.stringify(svc.row(id)) // "{}" + * for (const r of svc.rows()) // not iterable, at runtime only + * + * This rule reports those shapes. It is DELIBERATELY CONSERVATIVE and has no + * fixer: it fires only when the resolved type is the global `Promise`/ + * `PromiseLike` itself (never a merely-thenable library type such as MikroORM's + * QueryBuilder or supertest's Test, both of which are chained on purpose), only + * when every non-nullish member of a union is one, and never inside + * `Promise.all`/`allSettled`/`race`/`any`, on `.then`/`.catch`/`.finally`, on + * `expect(p).resolves`/`.rejects`, or in an existence/identity check + * (`p === undefined`, `p === null`, `p ?? q`, `a === b` between two promises) — + * see isExistenceOrIdentityCheck below. A finding is a bug: add the `await`. + * + * It also reports a bare `expect(p)` (never `expect.soft`/other wrappers) + * whose argument is a Promise, unless the call is immediately the object of a + * `.resolves`/`.rejects` access, of `.toBeInstanceOf(Promise)` (asking + * whether the value itself is a Promise IS the assertion, not a missing + * await), or of `.toBe(otherPromise)` where the argument is itself + * Promise-typed — the same in-flight-map identity idiom + * isExistenceOrIdentityCheck exempts for `===`, just spelled with a matcher + * (trek-photo-cache's stampede guard: `expect(svc.getInFlight(k)).toBe(fetch)` + * — awaiting would compare the resolved buffer to the unawaited Promise `fetch` + * and always fail). `expect(p).toBe(x)` is otherwise a vacuous assertion (it + * always passes: a Promise object is never `=== x`), same bug class as the + * rest of this rule but with its own message since the fix is either + * `expect(await p)` or `await expect(p).resolves…`, not a bare `await`. + */ +import ts from 'typescript'; + +const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void; +const PASSTHROUGH_PROPS = new Set(['then', 'catch', 'finally', 'resolves', 'rejects']); +const COMBINATORS = new Set(['all', 'allSettled', 'race', 'any']); +const EQUALITY_OPERATORS = new Set(['==', '!=', '===', '!==']); +// Callbacks whose result is consumed as a value, where a promise is always a bug +// (every promise is truthy, so the predicate degenerates). `.map`/`.reduce` are +// excluded on purpose: returning promises there feeds Promise.all legitimately. +const PREDICATE_METHODS = new Set([ + 'filter', + 'find', + 'findLast', + 'findIndex', + 'findLastIndex', + 'some', + 'every', + 'sort', +]); +const OBJECT_CONSUMERS = new Set(['keys', 'values', 'entries', 'assign', 'fromEntries']); + +/** `String(p)` / `Number(p)` / `Object.keys(p)` / `JSON.stringify(p)` / `Array.isArray(p)`. */ +function isValueConsumerCall(callee) { + if (callee.type === 'Identifier') return callee.name === 'String' || callee.name === 'Number'; + if (callee.type !== 'MemberExpression' || callee.computed) return false; + const { object, property } = callee; + if (object.type !== 'Identifier' || property.type !== 'Identifier') return false; + if (object.name === 'Object') return OBJECT_CONSUMERS.has(property.name); + if (object.name === 'JSON') return property.name === 'stringify'; + if (object.name === 'Array') return property.name === 'isArray'; + return false; +} + +function isCombinatorCall(node) { + const callee = node.callee; + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'Promise' && + callee.property.type === 'Identifier' && + COMBINATORS.has(callee.property.name) + ); +} + +/** Inside `Promise.all([...])` a promise IS the value; stop at the function boundary. */ +function isInsideCombinator(node) { + for (let cur = node.parent; cur; cur = cur.parent) { + if (cur.type === 'CallExpression' && isCombinatorCall(cur)) return true; + if (cur.type.includes('Function')) return false; + } + return false; +} + +function isNullLiteral(node) { + return (node.type === 'Literal' && node.value === null) || (node.type === 'Identifier' && node.name === 'undefined'); +} + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Disallow using a Promise as a plain value (element access, comparison, interpolation, spread, iteration) — the missing-await shapes the compiler cannot see with strict mode off.', + }, + schema: [], + messages: { + promiseAsValue: 'Promise used as a value (missing await?): {{ text }}', + expectPromise: 'Promise passed to expect() without .resolves/.rejects (missing await?)', + }, + }, + + create(context) { + const services = context.sourceCode.parserServices; + if (!services?.program || !services.esTreeNodeToTSNodeMap) return {}; + const checker = services.program.getTypeChecker(); + const reported = new WeakSet(); + + const isNullish = (type) => (type.flags & NULLISH_FLAGS) !== 0; + + function isPromiseType(type, tsNode) { + if (!type) return false; + if (type.isUnion()) { + const parts = type.types.filter((t) => !isNullish(t)); + return parts.length > 0 && parts.every((t) => isPromiseType(t, tsNode)); + } + const symbol = type.getSymbol() ?? type.aliasSymbol; + const name = symbol?.getName(); + if (name !== 'Promise' && name !== 'PromiseLike') return false; + const then = type.getProperty('then'); + if (!then) return false; + return checker.getTypeOfSymbolAtLocation(then, tsNode).getCallSignatures().length > 0; + } + + function typeOf(node) { + if (!node || node.type === 'AwaitExpression' || node.type === 'PrivateIdentifier') return null; + const tsNode = services.esTreeNodeToTSNodeMap.get(node); + return tsNode ? { type: checker.getTypeAtLocation(tsNode), tsNode } : null; + } + + function isPromise(node) { + const resolved = typeOf(node); + return !!resolved && isPromiseType(resolved.type, resolved.tsNode); + } + + function report(node) { + if (!node || reported.has(node) || isInsideCombinator(node)) return; + reported.add(node); + const raw = context.sourceCode.getText(node).replace(/\s+/g, ' '); + const text = raw.length > 60 ? `${raw.slice(0, 57)}...` : raw; + context.report({ node, messageId: 'promiseAsValue', data: { text } }); + } + + function check(node) { + if (isPromise(node)) report(node); + } + + function reportExpect(node) { + if (!node || reported.has(node)) return; + reported.add(node); + context.report({ node, messageId: 'expectPromise' }); + } + + /** Bare `expect(...)` only — `expect.soft(...)` and other wrappers are untouched. */ + function isBareExpectCall(node) { + return node.callee.type === 'Identifier' && node.callee.name === 'expect'; + } + + /** `expect(p).resolves` / `expect(p).rejects`, however it is chained further from there. */ + function isResolvesOrRejectsAccess(node) { + const parent = node.parent; + return ( + !!parent && + parent.type === 'MemberExpression' && + parent.object === node && + !parent.computed && + parent.property.type === 'Identifier' && + (parent.property.name === 'resolves' || parent.property.name === 'rejects') + ); + } + + /** + * `expect(p).toBeInstanceOf(Promise)` asks whether the VALUE ITSELF is a + * Promise object — that is the assertion, not a missing await, and + * awaiting it would change what the test checks. Exempt only that literal + * shape: the argument must resolve to the global `Promise` constructor, so + * `expect(f()).toBeInstanceOf(SomeOtherClass)` is still a reported bug. + */ + function isInstanceOfPromiseCheck(node) { + const memberParent = node.parent; + if ( + !memberParent || + memberParent.type !== 'MemberExpression' || + memberParent.object !== node || + memberParent.computed || + memberParent.property.type !== 'Identifier' || + memberParent.property.name !== 'toBeInstanceOf' + ) { + return false; + } + const callParent = memberParent.parent; + if (!callParent || callParent.type !== 'CallExpression' || callParent.callee !== memberParent) return false; + const arg = callParent.arguments[0]; + const resolved = arg && typeOf(arg); + if (!resolved) return false; + const symbol = resolved.type.getSymbol(); + return symbol?.getName() === 'PromiseConstructor'; + } + + /** + * `expect(p).toBe(otherPromise)` — an identity check between two Promise + * references (the in-flight-map stampede guard), not a resolved-value + * comparison. Mirrors isExistenceOrIdentityCheck's "both sides are + * promises" branch for `===`. + */ + function isPromiseIdentityToBe(node) { + const memberParent = node.parent; + if ( + !memberParent || + memberParent.type !== 'MemberExpression' || + memberParent.object !== node || + memberParent.computed || + memberParent.property.type !== 'Identifier' || + memberParent.property.name !== 'toBe' + ) { + return false; + } + const callParent = memberParent.parent; + if (!callParent || callParent.type !== 'CallExpression' || callParent.callee !== memberParent) return false; + const arg = callParent.arguments[0]; + return !!arg && isPromise(arg); + } + + /** + * `p === undefined` / `p === null` is how this codebase asks whether an + * in-flight promise is already parked in a map — a truthiness test there is + * what no-misused-promises rejects, so the explicit comparison IS the + * correct idiom (weather, collab, maps, exchange-rates, idempotency, the + * plugin drain, atlas). `chains.get(userId) === next` likewise asks whether + * the promise it stored is still the current one. Neither can be told apart + * from a missing await by type alone — with strictNullChecks off `Map.get()` + * is not even typed as nullable — so equality against nullish, and equality + * between two promises, are both exempt. `p === 'x'` still reports. + */ + function isExistenceOrIdentityCheck(node) { + if (isNullLiteral(node.left) || isNullLiteral(node.right)) return true; + return isPromise(node.left) && isPromise(node.right); + } + + return { + MemberExpression(node) { + const key = + node.property.type === 'Identifier' && !node.computed + ? node.property.name + : node.property.type === 'Literal' + ? node.property.value + : undefined; + if (typeof key === 'string' && PASSTHROUGH_PROPS.has(key)) return; + check(node.object); + }, + + BinaryExpression(node) { + // `p instanceof Promise` is the legitimate way to ask. + if (node.operator === 'instanceof') return; + if (EQUALITY_OPERATORS.has(node.operator) && isExistenceOrIdentityCheck(node)) return; + check(node.left); + check(node.right); + }, + + LogicalExpression(node) { + // `p ?? x` is the same existence check as `p === undefined`, and with + // strictNullChecks off the type cannot say whether it is a real one. + if (node.operator === '??') return; + // Only the left operand of `&&`/`||`: it is the one evaluated as a test, + // where a promise is unconditionally truthy. The right operand IS the + // expression's value and gets caught at whatever consumes it. + check(node.left); + }, + + TemplateLiteral(node) { + node.expressions.forEach(check); + }, + + SpreadElement(node) { + check(node.argument); + }, + + UnaryExpression(node) { + if (node.operator === 'typeof') check(node.argument); + }, + + ForOfStatement(node) { + if (!node.await) check(node.right); + }, + + ForInStatement(node) { + check(node.right); + }, + + CallExpression(node) { + if ( + isBareExpectCall(node) && + node.arguments.length > 0 && + node.arguments[0].type !== 'SpreadElement' && + !isResolvesOrRejectsAccess(node) && + !isInstanceOfPromiseCheck(node) && + !isPromiseIdentityToBe(node) && + isPromise(node.arguments[0]) + ) { + reportExpect(node.arguments[0]); + return; + } + if (isValueConsumerCall(node.callee)) { + node.arguments.forEach((arg) => { + if (arg.type !== 'SpreadElement') check(arg); + }); + return; + } + const callee = node.callee; + if ( + callee.type !== 'MemberExpression' || + callee.computed || + callee.property.type !== 'Identifier' || + !PREDICATE_METHODS.has(callee.property.name) + ) { + return; + } + for (const arg of node.arguments) { + if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') continue; + const resolved = typeOf(arg); + if (!resolved) continue; + const signatures = resolved.type.getCallSignatures(); + if ( + signatures.length > 0 && + signatures.every((sig) => isPromiseType(checker.getReturnTypeOfSignature(sig), resolved.tsNode)) + ) { + report(arg.body.type === 'BlockStatement' ? arg : arg.body); + } + } + }, + }; + }, +}; diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 4684c3ad6..0c7edecc3 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -4,6 +4,11 @@ import gitignore from 'eslint-config-flat-gitignore'; import eslintConfigPrettier from 'eslint-config-prettier'; import tseslint from 'typescript-eslint'; +import noPromiseAsValue from './eslint-rules/no-promise-as-value.mjs'; + +// Local rules live in eslint-rules/ and are exposed under the `trek/` prefix. +const trek = { rules: { 'no-promise-as-value': noPromiseAsValue } }; + export default tseslint.config( gitignore({ strict: false }), { @@ -25,9 +30,55 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier, + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + // Type-aware linting for src/: the project service asks TypeScript which + // config owns a file, the way an editor does, so there is no `project` + // array to keep in sync. Every file under src/ is in tsconfig.json's + // `include`, so the service resolves them all. + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + // tests/ needs the classic `project` instead. The project service looks for + // the tsconfig.json nearest the file and refuses a file that config does not + // include ("was not found by the project service"); tsconfig.json includes + // only `src`, and its `allowDefaultProject` escape hatch rejects any glob + // containing `**`, so it cannot cover a tree this size. `tsconfig.tests.json` + // (the one `npm run typecheck:tests` uses) includes src + tests, which is + // exactly the program these files need. + files: ['tests/**/*.ts'], + languageOptions: { + parserOptions: { + project: ['./tsconfig.tests.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, { files: ['src/**/*.ts', 'tests/**/*.ts'], + plugins: { trek }, rules: { + // trek/no-promise-as-value closes the hole the two rules below leave open: + // with `strict: false`, a promise used as a plain VALUE (element access, + // comparison, interpolation, spread, iteration, Object.keys/JSON.stringify) + // type-checks clean and silently produces `any`, `false` or `[object + // Promise]`. See eslint-rules/no-promise-as-value.mjs. + 'trek/no-promise-as-value': 'error', + // --- The promise safety net for the Phase 1 async sweep (ORM migration) --- + // Turning a DB-touching method `async` makes every unawaited call site a + // silently reordered write, and every `if (svc.exists(id))` permanently + // true — neither of which the type checker reports. These two rules are + // the only thing that catches them, so they are errors, not warnings. + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + '@typescript-eslint/no-misused-promises': [ + 'error', + { checksConditionals: true, checksVoidReturn: false, checksSpreads: true }, + ], // --- Severities tuned to keep CI green on a codebase that was never linted --- // (each rule below has pre-existing violations; surfaced as warnings, not blockers) '@typescript-eslint/no-explicit-any': 'warn', @@ -106,6 +157,124 @@ export default tseslint.config( ], }, }, + { + // D4: the driver is an implementation detail of src/db. Nothing else in + // src/ names `better-sqlite3` — a domain module that imports it is coupled + // to the engine the ORM exists to abstract, and (for the value import) + // reaches a connection the DI graph never handed it. + // + // `no-restricted-imports` is repeated rather than added to the src/services + // block above because ESLint flat config REPLACES a rule's options with the + // last matching config object's — a block that set only the driver path + // would silently drop the services wall for every file it matches. + files: ['src/**/*.ts'], + ignores: [ + // The connection and the ORM's bound driver: this is where the engine lives. + 'src/db/database.ts', + // openDatabase(): every better-sqlite3 handle the server opens (#2518). + 'src/db/connection.ts', + 'src/db/orm-driver.ts', + 'src/db/durability.ts', + // The plugin sandbox opens its own per-plugin database files, deliberately + // outside the app's ORM and connection. + 'src/nest/plugins/host/plugin-data.service.ts', + // Frozen forever (ruling 7): invoked from inside numbered, already-shipped + // MikroORM migrations — rewriting either onto the ORM would change what a + // re-run of an old migration against an old schema snapshot produces, + // which is never done to a landed migration. Not Plan 4's or any future + // plan's to touch. + 'src/db/reseat-booked-nights.ts', + 'src/db/document-provider-seed.ts', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'better-sqlite3', + message: + 'The SQLite driver is an implementation detail of src/db. Go through the ORM (src/db/entities + src/db/repositories) or UnitOfWork; the handle itself is owned by src/db/database.ts.', + }, + ], + patterns: [ + { + group: ['**/services/*', '**/services/**/*'], + message: + 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', + }, + ], + }, + ], + }, + }, + { + // D4/D10: a repository is dialect-neutral. It talks to the ORM through + // @mikro-orm/core and @mikro-orm/sql — never to a concrete driver package, + // never to better-sqlite3 (the driver is wired once, in src/db/orm-driver.ts) + // — and it never spells raw SQL inline: every database function goes through + // src/db/dialect/sql-functions.ts, which dispatches on the live platform. + // + // Both rules restate the blocks above for the same flat-config reason: the + // last matching config object's options REPLACE, not extend, the earlier + // ones, so dropping either restatement would open a hole under + // src/db/repositories/ only. + files: ['src/db/repositories/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@mikro-orm/sqlite', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + { + name: '@mikro-orm/postgresql', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + { + name: 'better-sqlite3', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + ], + patterns: [ + { + group: ['**/services/*', '**/services/**/*'], + message: + 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: "MemberExpression[object.name='process'][property.name='env']", + message: + 'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.', + }, + { + selector: "MemberExpression[object.name='process'][property.value='env']", + message: + 'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.', + }, + { + selector: "CallExpression[callee.name='raw']", + message: + 'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.', + }, + { + selector: "TaggedTemplateExpression[tag.name='sql']", + message: + 'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.', + }, + ], + }, + }, { // The plugin RPC decorator kit is written to stay extractable into its own // package, so its dependencies are pinned to Nest plus the two host modules it diff --git a/server/package.json b/server/package.json index 5555cf055..348f3856f 100644 --- a/server/package.json +++ b/server/package.json @@ -10,8 +10,12 @@ "reset-admin": "node reset-admin.js", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", + "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit", + "db:call-graph": "node scripts/db-call-graph.mjs", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", + "gen:entities": "node --import tsx scripts/generate-entities.ts", + "check:entities": "node --import tsx scripts/generate-entities.ts --check", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -24,35 +28,48 @@ "test:e2e": "vitest run tests/e2e", "test:coverage": "vitest run --coverage" }, + "mikro-orm": { + "configPaths": [ + "./src/mikro-orm.config.ts", + "./dist/mikro-orm.config.js" + ] + }, "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", + "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/migrations": "^7.2.1", + "@mikro-orm/nestjs": "^7.1.0", + "@mikro-orm/postgresql": "^7.2.1", + "@mikro-orm/seeder": "^7.2.1", + "@mikro-orm/sql": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", - "@nestjs/common": "^11.1.24", + "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.1.24", - "@nestjs/platform-express": "^11.1.24", - "@nestjs/platform-ws": "^11.1.29", + "@nestjs/core": "^11.2.5", + "@nestjs/platform-express": "^11.2.5", + "@nestjs/platform-ws": "^11.2.5", "@nestjs/schedule": "^6.1.3", - "@nestjs/swagger": "^11.4.5", - "@nestjs/websockets": "^11.1.29", - "@simplewebauthn/server": "^13.3.2", + "@nestjs/swagger": "^11.4.7", + "@nestjs/websockets": "^11.2.5", + "@simplewebauthn/server": "^13.3.3", "@trek/shared": "*", - "archiver": "^6.0.1", + "archiver": "^6.0.2", "bcryptjs": "^2.4.3", "better-sqlite3": "^13.0.3", - "compression": "^1.8.0", + "compression": "^1.8.2", "cookie-parser": "^1.4.7", - "cors": "^2.8.5", + "cors": "^2.8.6", "cron": "^4.4.0", - "dotenv": "^16.4.1", + "dotenv": "^16.6.1", "exifr": "7.1.3", - "express": "^4.18.3", - "fast-xml-parser": "^5.5.10", - "helmet": "^8.1.0", + "express": "^4.22.3", + "fast-xml-parser": "^5.11.1", + "helmet": "^8.3.0", "jimp": "^1.6.1", "json5": "^2.2.3", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", + "kysely": "^0.29.6", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -60,16 +77,16 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "semver": "^7.7.4", + "semver": "^7.8.5", "tsconfig-paths": "^4.2.0", - "tsx": "^4.21.0", - "typescript": "^6.0.2", + "tsx": "^4.23.15", + "typescript": "^6.0.3", "tz-lookup": "^6.1.25", "undici": "^7.30.0", - "unzipper": "^0.12.3", - "uuid": "^14.0.0", - "ws": "^8.21.0", - "zod": "^4.3.6" + "unzipper": "^0.12.5", + "uuid": "^14.0.2", + "ws": "^8.21.3", + "zod": "^4.6.5" }, "overrides": { "hono": "^4.12.16", @@ -77,45 +94,48 @@ "picomatch": "^4.0.4", "ip-address": "^10.1.1", "multer": "^2.3.0", - "ws": "^8.21.0", + "ws": "^8.21.3", "qs": "^6.15.2", "file-type": "^21.3.4" }, "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@nestjs/testing": "^11.1.24", - "@swc/core": "^1.15.40", + "@mikro-orm/core": "^7.2.1", + "@mikro-orm/entity-generator": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", + "@nestjs/testing": "^11.2.5", + "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", - "@types/compression": "^1.8.0", + "@types/compression": "^1.8.1", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^25.5.0", - "@types/nodemailer": "^8.0.1", - "@types/qrcode": "^1.5.5", - "@types/semver": "^7.7.1", + "@types/multer": "^2.2.0", + "@types/node": "^25.9.8", + "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", + "@types/semver": "^7.8.0", "@types/supertest": "^6.0.3", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", "@vitest/coverage-istanbul": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.18.0", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "nodemon": "^3.1.0", - "prettier": "^3.8.3", + "eslint": "^9.39.5", + "eslint-config-flat-gitignore": "^2.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "nodemon": "^3.1.14", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", "supertest": "^7.2.2", - "typescript-eslint": "^8.58.2", - "unplugin-swc": "^1.5.9", + "typescript-eslint": "^8.70.0", + "unplugin-swc": "^1.6.0", "vitest": "^4.1.11" } } diff --git a/server/scripts/__fixtures__/sync-db-sample.ts b/server/scripts/__fixtures__/sync-db-sample.ts new file mode 100644 index 000000000..5ed794cbf --- /dev/null +++ b/server/scripts/__fixtures__/sync-db-sample.ts @@ -0,0 +1,23 @@ +/** + * Fixture for `db-call-graph.mjs --sync --tx --root scripts/__fixtures__`: one class that + * still touches the database synchronously and opens a raw transaction, so the two gates + * CI runs (`--sync`, `--tx`) each have a known-bad case to report. `unawaited-sample.ts` + * beside it is clean under both flags. + */ + +interface RawDb { + get(sql: string, ...args: unknown[]): unknown; + transaction(fn: () => T): T; +} + +export class SyncSample { + private db: RawDb = { get: () => undefined, transaction: (fn) => fn() }; + + readSync(id: number): unknown { + return this.db.get('SELECT 1 WHERE id = ?', id); // sync DB-touching method + } + + writeInTx(): void { + this.db.transaction(() => undefined); // raw transaction site + } +} diff --git a/server/scripts/__fixtures__/unawaited-sample.ts b/server/scripts/__fixtures__/unawaited-sample.ts new file mode 100644 index 000000000..0de9cef92 --- /dev/null +++ b/server/scripts/__fixtures__/unawaited-sample.ts @@ -0,0 +1,60 @@ +/** + * Fixture for `db-call-graph.mjs --unawaited --root scripts/__fixtures__`. Not part of the + * server build or its typecheck — it exists only so the detector has something small and + * known-good/known-bad to run against. Three GOOD call shapes should produce no findings; + * five BAD ones should each produce exactly one, with a distinct reason tag. + */ + +class Dep { + async fetchValue(): Promise { + return 1; + } +} + +export class Sample { + private dep: Dep = new Dep(); + + async loadOne(): Promise { + return 1; + } + + // ---- GOOD (3): correctly consumed, nothing to report ---- + + async good1() { + return await this.loadOne(); // awaited + } + + async good2() { + return this.loadOne(); // returned directly — propagates the promise to the caller + } + + async good3() { + await Promise.all([this.loadOne(), this.dep.fetchValue()]); // Promise.all combinator + } + + // ---- BAD (5): used without await, one per reason tag ---- + + async bad1() { + const x = this.loadOne(); // stored-then-used + return { x }; + } + + async bad2() { + console.log(this.dep.fetchValue()); // argument + } + + async bad3() { + return { value: this.loadOne() }; // property + } + + async bad4() { + if (this.loadOne()) { + return 1; + } + return 0; // condition + } + + async bad5() { + this.loadOne(); // statement — result discarded + } +} diff --git a/server/scripts/build.mjs b/server/scripts/build.mjs index 24deb830a..0c840f5b4 100644 --- a/server/scripts/build.mjs +++ b/server/scripts/build.mjs @@ -1,8 +1,39 @@ import { execSync } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// Resolved from this file rather than cwd, so the directory that gets removed is +// always this package's own dist no matter where the script is invoked from. +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const distDir = join(packageRoot, 'dist'); + +// tsc only ever adds to dist, so a module deleted or renamed in src leaves its +// compiled twin behind. For most code that is merely stale, but the migrator +// globs dist/db/migrations at boot: an orphan there is a live migration with no +// source, and it runs. Same for dist/db/seeders. Nothing else writes into dist — +// the client bundle goes to server/public and the runtime assets the Dockerfile +// needs are copied in separately — so it is safe to drop wholesale. +rmSync(distDir, { recursive: true, force: true }); + +let tscExitedNonZero = false; try { - execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' }); + execSync('tsc -p tsconfig.build.json', { stdio: 'inherit', cwd: packageRoot }); } catch { + tscExitedNonZero = true; +} + +// `noEmitOnError` is off, so tsc exits non-zero on type errors and still writes +// dist — that case is deliberate, and gated by `npm run typecheck`. A tsc that +// never ran at all (not on PATH, crashed) is indistinguishable from here, and +// now that dist is emptied first it would leave nothing behind while still +// reporting success. Checking for the entry point tells the two apart. +if (!existsSync(join(distDir, 'index.js'))) { + console.error('[build] tsc emitted nothing — dist is empty. Run via `npm run build` so tsc is on PATH.'); + process.exit(1); +} + +if (tscExitedNonZero) { console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).'); } diff --git a/server/scripts/db-call-graph.mjs b/server/scripts/db-call-graph.mjs new file mode 100644 index 000000000..9642616f0 --- /dev/null +++ b/server/scripts/db-call-graph.mjs @@ -0,0 +1,1021 @@ +#!/usr/bin/env node +/** + * The Phase 1 (async sweep) worklist and gate. + * + * WHAT IT MEASURES + * --sync every *synchronous* method under `server/src/**` that touches the + * database — directly (a `get/all/run/prepare/transaction/exec/pragma` + * or a `canAccessTrip/isOwner/rosterUserIds/getPlaceWithTags` call on a + * DatabaseService / raw handle) or transitively through a chain of + * *synchronous* `this.*` calls that does. An `async` callee is a + * stopping point for this walk: it is not traversed into, and its own + * DB reach (direct or transitive) is not attributed to the caller — so + * a sync method that only calls already-async methods (e.g. an + * `onApplicationBootstrap()` hook that schedules an async cron + * callback) is not listed here. That shape is `--unawaited`'s job, not + * `--sync`'s. These are exactly the methods the sweep's R1 turns into + * `async` ones. + * --tx every real `…​.transaction(` call site (the AST sees calls, so the six + * prose mentions inside doc comments that `grep` finds are not here). + * These are the sites R2 routes through `UnitOfWork.transactional`. + * --unawaited every call expression of the form `this..(…)`, + * `this.(…)`, or `(…)` (imported from a relative path + * resolving under `src/`) whose target resolves to an `async` + * method/function, where the call's result is used without `await` + * in a way that compiles under `strict: false` but is a live bug — + * stored in a variable then read, passed as a plain argument, used + * as an object-literal property value, tested in a condition or + * comparison, or member-accessed. Each finding carries a reason tag + * (`stored-then-used` / `argument` / `property` / `condition` / + * `compared` / `member` / `statement` / `other`). It does NOT flag + * `await x()`, `return x()` (any function), `void x()`, an argument + * of `Promise.all/allSettled/race/any`, the concise body of an arrow + * passed to `.map(` (the `Promise.all(arr.map(…))` pattern — but it + * DOES flag that shape for `.filter/.find/.some/.every/.sort/.forEach`), + * or a `.then(`/`.catch(`/`.finally(` chain (which also covers the + * fire-and-forget-with-`.catch(` case). It is a lint-shaped finder + * for shapes the type-aware ESLint rules (`no-floating-promises`, + * `no-misused-promises`) cannot see under `strict: false` — see the + * recipe for why those rules alone aren't enough during Phase 1. + * + * IT DOES NOT RESOLVE: calls through a `.bind` alias, a call reached + * via a local variable holding a method/function reference, a call + * on a dependency typed as an interface/token/union (same limit as + * `--sync`), or a call to a `export default`-exported function. Those + * stay the manual-grep's job — see the recipe. + * + * It exits 1 as soon as any requested list is non-empty, so a task can gate on it: + * `node scripts/db-call-graph.mjs --sync --tx --domains nest/days,nest/budget` + * + * R0 EXCEPTIONS (never listed, by either flag): `DatabaseService` itself, + * `src/db/**`, `src/demo/**` and `src/nest/plugins/host/plugin-data.service.ts`. + * They stay synchronous for the whole of Phase 1 — see the recipe's R0. + * + * IT IS SYNTACTIC. It parses with `ts.createSourceFile` and no type checker, so + * it resolves `this..()` by the dependency's *written* type name: a + * dependency typed as an interface, a token or a union is not followed, and a + * call through a local (`const s = f(); s.get(…)`) is not attributed. It is a + * worklist and a ratchet, not a proof. Derived from the one-shot inventory pass + * that produced `docs/superpowers/plans/phase1-inventory.{md,json}`; Appendix C + * there lists the same accuracy caveats in full. + * + * LIFETIME: Plan 4 deletes this script along with `DatabaseService` — once no + * raw handle is left to call synchronously, there is nothing for it to count. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SERVER = path.resolve(HERE, '..'); +const REPO = path.resolve(SERVER, '..'); +const require_ = createRequire(path.join(REPO, 'package.json')); +const ts = require_('typescript'); + +let SRC = path.join(SERVER, 'src'); + +// ---------------------------------------------------------------- CLI +function parseArgs(argv) { + const opts = { sync: false, tx: false, unawaited: false, domains: null, json: false, root: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--sync') opts.sync = true; + else if (a === '--tx') opts.tx = true; + else if (a === '--unawaited') opts.unawaited = true; + else if (a === '--json') opts.json = true; + else if (a === '--domains') opts.domains = splitDomains(argv[++i]); + else if (a.startsWith('--domains=')) opts.domains = splitDomains(a.slice('--domains='.length)); + // --root is intentionally undocumented: it exists so tests/unit/scripts/db-call-graph.test.ts + // can point the whole analysis at a tiny sample tree instead of server/src. + else if (a === '--root') opts.root = argv[++i]; + else if (a.startsWith('--root=')) opts.root = a.slice('--root='.length); + else if (a === '--help' || a === '-h') opts.help = true; + else { + process.stderr.write(`db-call-graph: unknown argument ${a}\n`); + opts.help = true; + opts.bad = true; + } + } + // No selector: report both --sync and --tx, which is what a task's gate wants. + // --unawaited is never implied — it's opt-in only. + if (!opts.sync && !opts.tx && !opts.unawaited) { + opts.sync = true; + opts.tx = true; + } + return opts; +} +function splitDomains(value) { + if (!value) return null; + const list = value + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + return list.length ? list : null; +} +const USAGE = `Usage: node scripts/db-call-graph.mjs [--sync] [--tx] [--unawaited] [--domains a,b,…] [--json] + + --sync list synchronous DB-touching methods (direct + transitive) + --tx list real transaction call sites + --unawaited list calls to async methods/functions whose result is used without await + --domains a,b restrict to these inventory domain names (nest/days, systemNotices, …) + --json machine-readable output + (no --sync/--tx/--unawaited) --sync and --tx together + +Exits 1 when a requested list is non-empty. R0 exceptions (DatabaseService, +src/db/**, src/demo/**, plugins/host/plugin-data.service.ts) are never listed. +`; + +const opts = parseArgs(process.argv.slice(2)); +if (opts.help) { + process.stdout.write(USAGE); + process.exit(opts.bad ? 2 : 0); +} +if (opts.root) { + SRC = path.resolve(process.cwd(), opts.root); +} + +// ---------------------------------------------------------------- file walk +function walk(dir, acc = []) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + const r = path.relative(SRC, p).replace(/\\/g, '/'); + // Generated/declarative trees with no call graph of their own. + if (r === 'db/migrations' || r === 'db/seeders' || r === 'db/entities' || r === 'typings') continue; + walk(p, acc); + } else if (ent.name.endsWith('.ts') && !ent.name.endsWith('.d.ts')) { + const r = path.relative(SRC, p).replace(/\\/g, '/'); + if (r === 'db/migrations.ts') continue; // legacy test-only schema replay + acc.push(p); + } + } + return acc; +} +const files = walk(SRC).sort(); + +// ---------------------------------------------------------------- constants +const DB_METHODS = new Set(['get', 'all', 'run', 'prepare', 'transaction', 'exec', 'pragma']); +const DB_HELPERS = new Set(['canAccessTrip', 'isOwner', 'rosterUserIds', 'getPlaceWithTags']); +const DB_IDENT_NAMES = new Set(['db', 'conn', 'tx', 'database', 'dbs']); +const DB_PROP_NAMES = new Set(['db', 'database', 'dbs']); +const DB_SERVICE_TYPES = new Set(['DatabaseService']); + +/** R0: the raw seam and the out-of-scope trees. See the recipe's R0. */ +const R0_CLASSES = new Set(['DatabaseService']); +function isR0File(relFile) { + return ( + relFile.startsWith('src/db/') || + relFile.startsWith('src/demo/') || + relFile === 'src/nest/plugins/host/plugin-data.service.ts' + ); +} + +function relServer(p) { + return path.relative(SERVER, p).replace(/\\/g, '/'); +} + +function domainOf(file) { + const r = path.relative(SRC, file).replace(/\\/g, '/'); + if (r.startsWith('nest/')) { + const seg = r.split('/')[1]; + return seg.includes('.') ? 'nest(root)' : `nest/${seg}`; + } + if (r.startsWith('nest-mcp/')) return 'nest-mcp'; + if (r.startsWith('systemNotices/')) return 'systemNotices'; + if (r.startsWith('demo/')) return 'demo'; + if (r.startsWith('mcp/')) return 'mcp'; + if (r.startsWith('middleware/')) return 'middleware'; + if (r.startsWith('db/')) return 'db'; + if (r.startsWith('app-config/')) return 'app-config'; + if (r.startsWith('utils/')) return 'utils'; + return 'src(root)'; +} + +// ---------------------------------------------------------------- parse pass +/** @type {Map} className -> first record with that name */ +const classes = new Map(); +/** @type {any[]} */ +const allClasses = []; +const txSites = []; + +function lineOf(sf, node) { + return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; +} + +function typeNameOf(typeNode) { + if (!typeNode) return null; + if (ts.isTypeReferenceNode(typeNode)) { + const n = typeNode.typeName; + return ts.isIdentifier(n) ? n.text : n.getText(); + } + return typeNode.getText().replace(/\s+/g, ' ').slice(0, 60); +} + +for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, /* setParentNodes */ true, ts.ScriptKind.TS); + + // Identifiers imported from db/database denote a raw handle / helper. + const dbDatabaseImports = new Set(); + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st) || !st.importClause) continue; + const spec = st.moduleSpecifier.text; + const names = []; + if (st.importClause.name) names.push(st.importClause.name.text); + const nb = st.importClause.namedBindings; + if (nb && ts.isNamedImports(nb)) for (const e of nb.elements) names.push(e.name.text); + if (nb && ts.isNamespaceImport(nb)) names.push(nb.name.text); + for (const n of names) if (/(^|\/)db\/database$/.test(spec)) dbDatabaseImports.add(n); + } + + const fileRel = relServer(file); + const fileDomain = domainOf(file); + + // ---- gather class-likes and module-level functions + const containers = []; + for (const st of sf.statements) { + if (ts.isClassDeclaration(st) && st.name) containers.push({ kind: 'class', node: st, name: st.name.text }); + } + const moduleFns = []; + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.body) { + moduleFns.push({ + name: st.name.text, + node: st, + async: !!st.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + }); + } + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && ts.isIdentifier(d.name)) { + moduleFns.push({ + name: d.name.text, + node: d.initializer, + async: !!d.initializer.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + }); + } + } + } + } + if (moduleFns.length) { + containers.push({ kind: 'module', node: sf, name: `${path.basename(file, '.ts')} (module)`, fns: moduleFns }); + } + + for (const c of containers) { + const deps = {}; // propName -> written type name + const dbProps = new Set(); + const methods = []; + let classRec; + + if (c.kind === 'class') { + for (const m of c.node.members) { + if (ts.isConstructorDeclaration(m)) { + for (const p of m.parameters) { + if (!ts.isIdentifier(p.name)) continue; + const tn = typeNameOf(p.type); + deps[p.name.text] = tn; + if (DB_SERVICE_TYPES.has(tn) || DB_PROP_NAMES.has(p.name.text)) dbProps.add(p.name.text); + } + } else if (ts.isPropertyDeclaration(m) && ts.isIdentifier(m.name)) { + const tn = typeNameOf(m.type); + if (tn && !deps[m.name.text]) deps[m.name.text] = tn; + if ((tn && DB_SERVICE_TYPES.has(tn)) || DB_PROP_NAMES.has(m.name.text)) dbProps.add(m.name.text); + } + } + // `private get db() { return this.dbs.connection; }` — the alias 14 services use. + for (const m of c.node.members) { + if (ts.isGetAccessorDeclaration(m) && m.name && ts.isIdentifier(m.name) && m.body) { + const stmts = m.body.statements; + const only = stmts.length === 1 && ts.isReturnStatement(stmts[0]) ? stmts[0].expression : null; + const aliasesDb = + only && + ts.isPropertyAccessExpression(only) && + ((only.expression.kind === ts.SyntaxKind.ThisKeyword && dbProps.has(only.name.text)) || + (only.name.text === 'connection' && + ts.isPropertyAccessExpression(only.expression) && + only.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + dbProps.has(only.expression.name.text))); + const tn = typeNameOf(m.type); + if (aliasesDb || (tn && DB_SERVICE_TYPES.has(tn))) { + dbProps.add(m.name.text); + if (!deps[m.name.text]) deps[m.name.text] = 'DatabaseService'; + } + } + } + for (const m of c.node.members) { + const mods = ts.getModifiers(m); + if (ts.isMethodDeclaration(m) && m.name && m.body) { + methods.push({ + name: m.name.getText(sf), + kind: 'method', + body: m.body, + async: !!mods?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword), + generator: !!m.asteriskToken, // async *foo() — consumed via `for await`/`yield*`, not `await`; see --unawaited + line: lineOf(sf, m), + }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + (ts.isArrowFunction(m.initializer) || ts.isFunctionExpression(m.initializer)) && + m.name + ) { + methods.push({ + name: m.name.getText(sf), + kind: 'method', + body: m.initializer.body, + async: !!m.initializer.modifiers?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword), + line: lineOf(sf, m), + }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + m.name && + (ts.isCallExpression(m.initializer) || + ts.isNewExpression(m.initializer) || + ts.isObjectLiteralExpression(m.initializer) || + ts.isArrayLiteralExpression(m.initializer)) + ) { + // e.g. `private readonly supervisor = create(fn, { onStatus: () => this.db.… })` + methods.push({ name: m.name.getText(sf), kind: 'field-init', body: m.initializer, async: false, line: lineOf(sf, m) }); + } else if (ts.isConstructorDeclaration(m) && m.body) { + methods.push({ name: 'constructor', kind: 'constructor', body: m.body, async: false, line: lineOf(sf, m) }); + } else if (ts.isGetAccessorDeclaration(m) && m.body && m.name) { + methods.push({ name: m.name.getText(sf), kind: 'getter', body: m.body, async: false, line: lineOf(sf, m) }); + } + } + classRec = { name: c.name, kind: 'class', file: fileRel, domain: fileDomain, deps, dbProps: [...dbProps], methods: [] }; + } else { + for (const f of c.fns) { + methods.push({ name: f.name, kind: 'function', body: f.node.body, async: f.async, line: lineOf(sf, f.node) }); + } + classRec = { name: c.name, kind: 'module', file: fileRel, domain: fileDomain, deps: {}, dbProps: [], methods: [] }; + } + + // ---- receiver classification + function receiverKind(expr) { + if (ts.isIdentifier(expr)) { + if (dbDatabaseImports.has(expr.text)) return { text: expr.text }; + if (DB_IDENT_NAMES.has(expr.text)) return { text: expr.text }; + return null; + } + if (ts.isPropertyAccessExpression(expr)) { + if (expr.expression.kind === ts.SyntaxKind.ThisKeyword && dbProps.has(expr.name.text)) { + return { text: `this.${expr.name.text}` }; + } + if ( + expr.name.text === 'connection' && + ts.isPropertyAccessExpression(expr.expression) && + expr.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + dbProps.has(expr.expression.name.text) + ) { + return { text: `this.${expr.expression.name.text}.connection` }; + } + if (expr.expression.kind === ts.SyntaxKind.ThisKeyword && expr.name.text === 'conn') { + return { text: 'this.conn' }; + } + } + return null; + } + + // ---- walk each method body + for (const m of methods) { + const dbCalls = []; + const calls = []; // {kind:'self'|'dep', dep, depType, method} + + const visit = (node) => { + if (node !== m.body && (ts.isClassDeclaration(node) || ts.isClassExpression(node))) return; + if (ts.isCallExpression(node)) { + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee)) { + const name = callee.name.text; + const recv = receiverKind(callee.expression); + if (recv && (DB_METHODS.has(name) || DB_HELPERS.has(name))) { + dbCalls.push({ recv: recv.text, method: name, line: lineOf(sf, node) }); + if (name === 'transaction') { + txSites.push({ + file: fileRel, + domain: fileDomain, + class: classRec.name, + method: m.name, + line: lineOf(sf, node), + receiver: recv.text, + }); + } + } + if (callee.expression.kind === ts.SyntaxKind.ThisKeyword) { + calls.push({ kind: 'self', method: name }); + } else if ( + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + const dep = callee.expression.name.text; + if (Object.prototype.hasOwnProperty.call(deps, dep)) { + calls.push({ kind: 'dep', dep, depType: deps[dep], method: name }); + } + } + } else if (ts.isIdentifier(callee)) { + if (DB_HELPERS.has(callee.text) && dbDatabaseImports.has(callee.text)) { + dbCalls.push({ recv: '(import)', method: callee.text, line: lineOf(sf, node) }); + } + if (c.kind === 'module' && moduleFns.some((f) => f.name === callee.text)) { + calls.push({ kind: 'self', method: callee.text }); + } + } + } + ts.forEachChild(node, visit); + }; + if (m.body) visit(m.body); + + classRec.methods.push({ + name: m.name, + kind: m.kind, + line: m.line, + async: m.async, + generator: !!m.generator, + dbCalls, + dbCallCount: dbCalls.length, + calls, + }); + } + + allClasses.push(classRec); + if (!classes.has(classRec.name)) classes.set(classRec.name, classRec); + } +} + +// ------------------------------------------------- transitive DB closure +const methodKey = (cls, m) => `${cls}#${m}`; +const methodIndex = new Map(); +for (const c of allClasses) for (const m of c.methods) methodIndex.set(methodKey(c.name, m.name), { cls: c, m }); + +function calleeKeys(c, m) { + const out = []; + for (const call of m.calls) { + if (call.kind === 'self') out.push(methodKey(c.name, call.method)); + else if (call.kind === 'dep' && call.depType && classes.has(call.depType)) out.push(methodKey(call.depType, call.method)); + } + return out; +} + +for (const c of allClasses) for (const m of c.methods) m.directDb = m.dbCallCount > 0; + +for (const c of allClasses) { + for (const m of c.methods) { + const seen = new Set(); + const stack = calleeKeys(c, m).slice(); + const dbCallees = new Set(); + while (stack.length) { + const k = stack.pop(); + if (seen.has(k)) continue; + seen.add(k); + const t = methodIndex.get(k); + if (!t) continue; + // An async callee stops the walk: neither its own DB reach nor anything further + // behind it is attributed to the (sync) caller — that's --unawaited's job. See header. + if (t.m.async) continue; + if (t.m.directDb) dbCallees.add(k); + for (const nk of calleeKeys(t.cls, t.m)) if (!seen.has(nk)) stack.push(nk); + } + m.transitiveDbCallees = [...dbCallees].sort(); + m.touchesDb = m.directDb || dbCallees.size > 0; + } +} + +// ------------------------------------------------- selection +const domainFilter = opts.domains ? new Set(opts.domains) : null; +const unknownDomains = domainFilter + ? [...domainFilter].filter((d) => !allClasses.some((c) => c.domain === d) && !files.some((f) => domainOf(f) === d)) + : []; +function inScope(rec) { + if (isR0File(rec.file) || R0_CLASSES.has(rec.name)) return false; + if (domainFilter && !domainFilter.has(rec.domain)) return false; + return true; +} + +/** A `constructor` / field initializer / getter cannot simply become `async` — R1.3/R1.5/R1.6. */ +const CANNOT_BE_ASYNC = new Set(['constructor', 'field-init']); + +const syncMethods = []; +const restructureSites = []; +for (const c of allClasses) { + if (!inScope(c)) continue; + for (const m of c.methods) { + if (!m.touchesDb || m.async) continue; + const entry = { + domain: c.domain, + file: c.file, + class: c.name, + container: c.kind, + method: m.name, + methodKind: m.kind, + line: m.line, + direct: m.directDb, + dbCallSites: m.dbCallCount, + via: m.directDb ? [] : m.transitiveDbCallees, + }; + if (CANNOT_BE_ASYNC.has(m.kind)) restructureSites.push(entry); + else syncMethods.push(entry); + } +} +const txSelected = txSites.filter((t) => { + if (isR0File(t.file) || R0_CLASSES.has(t.class)) return false; + if (domainFilter && !domainFilter.has(t.domain)) return false; + return true; +}); + +const byDomainClass = (a, b) => a.domain.localeCompare(b.domain) || a.file.localeCompare(b.file) || a.line - b.line; +syncMethods.sort(byDomainClass); +restructureSites.sort(byDomainClass); +txSelected.sort(byDomainClass); + +// ------------------------------------------------- --unawaited +/** + * Everything below is purely syntactic (same posture as the rest of the file): + * it classifies a call expression's *immediate* AST context, and resolves its + * target through the `classes`/`deps` maps the main pass already built (for + * `this.` / `this..`) or a fresh per-file import map (for a plain + * `(…)` call). See the header comment for exactly what it does and + * does not see. + */ +/** Walk up through no-op-at-runtime wrappers (parens, `as`/`satisfies`, postfix `!`) and through + * a ternary branch (`cond ? call() : x` propagates `call()`'s result exactly like `call()` would + * on its own) to the expression a caller actually treats as "the call's result". */ +function effectiveNode(node) { + let n = node; + for (;;) { + const p = n.parent; + if (!p) return n; + if (ts.isParenthesizedExpression(p) || ts.isAsExpression(p) || ts.isNonNullExpression(p) || ts.isSatisfiesExpression?.(p)) { + n = p; + continue; + } + if (ts.isConditionalExpression(p) && (p.whenTrue === n || p.whenFalse === n)) { + n = p; + continue; + } + return n; + } +} +const COMPARE_OPS = new Set([ + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); +const LOGICAL_OPS = new Set([ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken]); +const ARROW_ALWAYS_REPORT = new Set(['filter', 'find', 'some', 'every', 'sort', 'forEach']); + +/** The property/method name of a call `X.(arrow)` when `arrow` is one of its arguments, else null. */ +function arrowCallContext(arrow) { + const call = arrow.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.includes(arrow)) return null; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return null; + return callee.name.text; +} +/** `eff` is (via an optional spread) a direct element of an array passed to Promise.all/allSettled/race/any. */ +function isPromiseCombinatorArrayElement(eff) { + let n = eff; + let p = n.parent; + if (p && ts.isSpreadElement(p)) { + n = p; + p = n.parent; + } + if (!p || !ts.isArrayLiteralExpression(p)) return false; + const call = p.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.includes(p)) return false; + const callee = call.expression; + return ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Promise' && + ['all', 'allSettled', 'race', 'any'].includes(callee.name.text) + ); +} +/** True when `node`'s result is used in a way this recipe treats as already-safe. */ +function isUnawaitedSkip(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return false; + if (ts.isAwaitExpression(parent) && parent.expression === eff) return true; + if (ts.isReturnStatement(parent) && parent.expression === eff) return true; + if (ts.isVoidExpression(parent) && parent.expression === eff) return true; + // A `.then(`/`.catch(`/`.finally(` chain — including the fire-and-forget-with-.catch( shape. + if ( + ts.isPropertyAccessExpression(parent) && + parent.expression === eff && + ['then', 'catch', 'finally'].includes(parent.name.text) && + parent.parent && + ts.isCallExpression(parent.parent) && + parent.parent.expression === parent + ) { + return true; + } + if (isPromiseCombinatorArrayElement(eff)) return true; + if (ts.isArrowFunction(parent) && parent.body === eff) { + const mc = arrowCallContext(parent); + return !(mc && ARROW_ALWAYS_REPORT.has(mc)); // default: implicit-return-like (the `.map(` pattern) — skip + } + return false; +} +/** `async *foo()` returns an AsyncGenerator, not a Promise — `await` doesn't consume it (and isn't + * expected to); only `for await (… of call())` or a delegating `yield* call()` does. */ +function isGeneratorConsumedSafely(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return false; + if (ts.isForOfStatement(parent) && parent.expression === eff && parent.awaitModifier) return true; + if (ts.isYieldExpression(parent) && parent.expression === eff && parent.asteriskToken) return true; + return false; +} +/** Reason tag for a call whose result is used without await (only called when NOT skipped). */ +function classifyUnawaitedShape(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return 'other'; + if (ts.isVariableDeclaration(parent) && parent.initializer === eff) return 'stored-then-used'; + if (ts.isPropertyAssignment(parent) && parent.initializer === eff) return 'property'; + if (ts.isBinaryExpression(parent)) { + const op = parent.operatorToken.kind; + if (COMPARE_OPS.has(op) && (parent.left === eff || parent.right === eff)) return 'compared'; + if (LOGICAL_OPS.has(op) && (parent.left === eff || parent.right === eff)) return 'condition'; + } + if (ts.isIfStatement(parent) && parent.expression === eff) return 'condition'; + if (ts.isConditionalExpression(parent) && parent.condition === eff) return 'condition'; + if (ts.isWhileStatement(parent) && parent.expression === eff) return 'condition'; + if (ts.isDoStatement(parent) && parent.expression === eff) return 'condition'; + if ((ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && parent.expression === eff) { + return 'member'; + } + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.arguments?.includes(eff)) return 'argument'; + if (ts.isExpressionStatement(parent)) return 'statement'; + if (ts.isArrowFunction(parent) && parent.body === eff) { + const mc = arrowCallContext(parent); + if (mc && ['filter', 'find', 'some', 'every'].includes(mc)) return 'condition'; // predicate result discarded + if (mc === 'forEach') return 'statement'; // return value discarded, same as an expression statement + } + return 'other'; +} + +function collectExportedFnAsyncness(sf) { + const out = new Map(); // name -> { async, generator } + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + out.set(st.name.text, { + async: !!st.modifiers.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + generator: !!st.asteriskToken, + }); + } else if (ts.isVariableStatement(st) && st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const d of st.declarationList.declarations) { + if ( + d.initializer && + (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && + ts.isIdentifier(d.name) + ) { + out.set(d.name.text, { + async: !!d.initializer.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + // Arrow functions can't be generators; a function expression can (`async function* () {}`). + generator: ts.isFunctionExpression(d.initializer) ? !!d.initializer.asteriskToken : false, + }); + } + } + } + } + return out; +} +/** Resolve a relative import specifier to a `.ts` file (or its `index.ts`) under the tree, else null. */ +function resolveModuleFile(fromFile, specifier) { + if (!specifier.startsWith('.')) return null; // node_modules / path-mapped — not "a file under src/" + const base = path.resolve(path.dirname(fromFile), specifier); + for (const cand of [`${base}.ts`, path.join(base, 'index.ts')]) { + if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; + } + return null; +} +/** Nearest enclosing function-like body of `node`, or null (e.g. a class-field initializer). */ +function findEnclosingFunctionBody(node) { + let n = node.parent; + while (n) { + if ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) + ) { + return n.body ?? null; + } + n = n.parent; + } + return null; +} +/** + * `const x = this.asyncMethod()` is only safe when *every* later read of `x` in its + * declaring function is itself one of the safe shapes `isUnawaitedSkip` recognizes + * (`await x`, `return x`, `Promise.all([x, …])`, …) — the in-flight/memoized-promise + * pattern (`this.inFlight.set(url, task); … return await task;`). Matches by name + * within the enclosing function body only (no real scope/shadowing analysis — see + * the header's limits); a variable this can't find a later read for is reported, + * same as the declaration-only heuristic would. + */ +function isVariableUsedSafely(declNode, varName) { + const scopeBody = findEnclosingFunctionBody(declNode); + if (!scopeBody) return false; + let sawReference = false; + let allSafe = true; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === varName && node !== declNode.name) { + const p = node.parent; + const isBindingOrPropertyName = + (ts.isVariableDeclaration(p) && p.name === node) || + (ts.isParameter(p) && p.name === node) || + (ts.isBindingElement(p) && p.name === node) || + (ts.isPropertyAccessExpression(p) && p.name === node); + if (!isBindingOrPropertyName) { + sawReference = true; + if (!isUnawaitedSkip(node)) allSafe = false; + } + } + ts.forEachChild(node, visit); + }; + visit(scopeBody); + return sawReference && allSafe; +} + +function classMemberBodies(classNode, sf) { + const out = []; + for (const m of classNode.members) { + if (ts.isMethodDeclaration(m) && m.name && m.body) { + out.push({ name: m.name.getText(sf), body: m.body }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + (ts.isArrowFunction(m.initializer) || ts.isFunctionExpression(m.initializer)) && + m.name + ) { + out.push({ name: m.name.getText(sf), body: m.initializer.body }); + } else if (ts.isConstructorDeclaration(m) && m.body) { + out.push({ name: 'constructor', body: m.body }); + } else if (ts.isGetAccessorDeclaration(m) && m.body && m.name) { + out.push({ name: m.name.getText(sf), body: m.body }); + } + } + return out; +} + +let unawaitedFindings = []; +if (opts.unawaited) { + const exportedFnsByFile = new Map(); + const parsed = new Map(); // file -> {sf, text} + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); + parsed.set(file, sf); + exportedFnsByFile.set(file, collectExportedFnAsyncness(sf)); + } + + function resolveCallTarget(node, classRec, selfAsyncByName, importMap) { + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee)) { + const methodName = callee.name.text; + const obj = callee.expression; + if (obj.kind === ts.SyntaxKind.ThisKeyword) { + const self = selfAsyncByName?.get(methodName); + if (self) return { desc: `this.${methodName}`, async: self.async, generator: self.generator }; + return null; + } + if (ts.isPropertyAccessExpression(obj) && obj.expression.kind === ts.SyntaxKind.ThisKeyword) { + const dep = obj.name.text; + const depType = classRec?.deps?.[dep]; + const depClass = depType ? classes.get(depType) : null; + const m = depClass?.methods.find((mm) => mm.name === methodName); + if (m) return { desc: `this.${dep}.${methodName}`, async: m.async, generator: !!m.generator }; + } + return null; + } + if (ts.isIdentifier(callee)) { + const imp = importMap.get(callee.text); + if (!imp) return null; + const asyncMap = exportedFnsByFile.get(imp.file); + const entry = asyncMap?.get(imp.imported); + if (!entry) return null; + return { desc: `import:${callee.text}`, async: entry.async, generator: entry.generator }; + } + return null; + } + + for (const file of files) { + const relFile = relServer(file); + if (isR0File(relFile)) continue; + const fileDomain = domainOf(file); + if (domainFilter && !domainFilter.has(fileDomain)) continue; + + const sf = parsed.get(file); + const importMap = new Map(); + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue; + const targetFile = resolveModuleFile(file, st.moduleSpecifier.text); + if (!targetFile) continue; + const clause = st.importClause; + const nb = clause.namedBindings; + if (nb && ts.isNamedImports(nb)) { + for (const el of nb.elements) { + importMap.set(el.name.text, { file: targetFile, imported: el.propertyName ? el.propertyName.text : el.name.text }); + } + } + // A default import's local name can't be matched against a named-export map — not resolved (see header). + } + + const record = (node, className, methodName) => { + const target = resolveCallTarget(node, className.classRec, className.selfAsyncByName, importMap); + if (!target || !target.async) return; + if (target.generator ? isGeneratorConsumedSafely(node) : isUnawaitedSkip(node)) return; + const reason = classifyUnawaitedShape(node); + if (reason === 'stored-then-used') { + const declNode = effectiveNode(node).parent; + if ( + ts.isVariableDeclaration(declNode) && + ts.isIdentifier(declNode.name) && + isVariableUsedSafely(declNode, declNode.name.text) + ) { + return; // every later read is already await'd / returned / combined — the in-flight-promise pattern + } + } + const text = node.getText(sf).replace(/\s+/g, ' ').trim(); + unawaitedFindings.push({ + domain: fileDomain, + file: relFile, + class: className.name, + method: methodName, + line: lineOf(sf, node), + call: text.length > 100 ? `${text.slice(0, 97)}...` : text, + target: target.desc, + reason, + }); + }; + + for (const st of sf.statements) { + if (!ts.isClassDeclaration(st) || !st.name) continue; + const className = st.name.text; + const classRec = allClasses.find((c) => c.file === relFile && c.name === className); + if (!classRec || R0_CLASSES.has(className)) continue; + const selfAsyncByName = new Map(); + for (const m of classRec.methods) { + if (!selfAsyncByName.has(m.name)) selfAsyncByName.set(m.name, { async: m.async, generator: !!m.generator }); + } + const ctx = { name: className, classRec, selfAsyncByName }; + + for (const member of classMemberBodies(st, sf)) { + const visit = (node) => { + if (node !== member.body && (ts.isClassDeclaration(node) || ts.isClassExpression(node))) return; + if (ts.isCallExpression(node)) record(node, ctx, member.name); + ts.forEachChild(node, visit); + }; + visit(member.body); + } + } + + // Module-level function bodies: only `(…)` calls apply (there is no `this`). + const moduleCtx = { name: `${path.basename(file, '.ts')} (module)`, classRec: null, selfAsyncByName: null }; + const moduleFnBodies = []; + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.body) moduleFnBodies.push({ name: st.name.text, body: st.body }); + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && ts.isIdentifier(d.name)) { + moduleFnBodies.push({ name: d.name.text, body: d.initializer.body }); + } + } + } + } + for (const fn of moduleFnBodies) { + const visit = (node) => { + if (ts.isCallExpression(node)) record(node, moduleCtx, fn.name); + ts.forEachChild(node, visit); + }; + visit(fn.body); + } + } + + unawaitedFindings.sort( + (a, b) => a.domain.localeCompare(b.domain) || a.file.localeCompare(b.file) || a.line - b.line, + ); +} + +// ------------------------------------------------- output +const out = []; +function group(entries, key) { + const m = new Map(); + for (const e of entries) { + const k = key(e); + if (!m.has(k)) m.set(k, []); + m.get(k).push(e); + } + return m; +} + +if (opts.json) { + out.push( + JSON.stringify( + { + scope: { + domains: opts.domains ?? 'all', + r0Exceptions: ['DatabaseService', 'src/db/**', 'src/demo/**', 'src/nest/plugins/host/plugin-data.service.ts'], + }, + syncDbMethods: opts.sync ? syncMethods : undefined, + syncDbMethodCount: opts.sync ? syncMethods.length : undefined, + cannotBeAsync: opts.sync ? restructureSites : undefined, + transactionSites: opts.tx ? txSelected : undefined, + transactionSiteCount: opts.tx ? txSelected.length : undefined, + unawaitedCalls: opts.unawaited ? unawaitedFindings : undefined, + unawaitedCallCount: opts.unawaited ? unawaitedFindings.length : undefined, + unknownDomains, + }, + null, + 2, + ), + ); +} else { + if (unknownDomains.length) { + out.push(`! no such domain: ${unknownDomains.join(', ')} (names come from the inventory, e.g. nest/days, systemNotices)`); + out.push(''); + } + if (opts.sync) { + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!syncMethods.length) { + out.push(`sync DB-touching methods: none (${scope})`); + } else { + const direct = syncMethods.filter((e) => e.direct).length; + out.push( + `sync DB-touching methods: ${syncMethods.length} (${direct} direct, ${syncMethods.length - direct} transitive) in ${scope}`, + ); + for (const [k, entries] of group(syncMethods, (e) => `${e.domain}\u0000${e.file}\u0000${e.class}`)) { + const [domain, file, cls] = k.split('\u0000'); + const d = entries.filter((e) => e.direct).length; + out.push(''); + out.push(` ${domain} · ${cls} · ${file} (${d} direct, ${entries.length - d} transitive)`); + for (const e of entries) { + const detail = e.direct ? `${e.dbCallSites} db call site${e.dbCallSites === 1 ? '' : 's'}` : `via ${e.via.join(', ')}`; + out.push(` ${String(e.line).padStart(5)} ${e.method} — ${detail}`); + } + } + } + if (restructureSites.length) { + out.push(''); + out.push(` note: ${restructureSites.length} sync DB context(s) that cannot simply become async (recipe R1.5/R1.6 —`); + out.push(' restructure, or call an async helper with `void h().catch(…)`); not counted, not gating:'); + for (const e of restructureSites) out.push(` ${e.file}:${e.line} ${e.class}.${e.method} (${e.methodKind})`); + } + } + if (opts.tx) { + if (opts.sync) out.push(''); + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!txSelected.length) { + out.push(`transaction sites: none (${scope})`); + } else { + out.push(`transaction sites: ${txSelected.length} in ${scope}`); + for (const t of txSelected) { + out.push(` ${t.file}:${t.line} ${t.class}.${t.method} — ${t.receiver}.transaction(`); + } + } + } + if (opts.unawaited) { + if (opts.sync || opts.tx) out.push(''); + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!unawaitedFindings.length) { + out.push(`unawaited async call sites: none (${scope})`); + } else { + out.push(`unawaited async call sites: ${unawaitedFindings.length} in ${scope}`); + const byReason = group(unawaitedFindings, (e) => e.reason); + const reasonOrder = ['stored-then-used', 'argument', 'property', 'condition', 'compared', 'member', 'statement', 'other']; + const reasonSummary = reasonOrder + .filter((r) => byReason.has(r)) + .map((r) => `${r}=${byReason.get(r).length}`) + .join(', '); + out.push(` by reason: ${reasonSummary}`); + for (const [k, entries] of group(unawaitedFindings, (e) => `${e.domain}\u0000${e.file}\u0000${e.class}`)) { + const [domain, file, cls] = k.split('\u0000'); + out.push(''); + out.push(` ${domain} · ${cls} · ${file}`); + for (const e of entries) { + out.push(` ${String(e.line).padStart(5)} [${e.reason}] ${e.method} — ${e.call} (→ ${e.target})`); + } + } + } + } +} + +process.stdout.write(out.join('\n') + '\n'); + +const found = + (opts.sync && syncMethods.length > 0) || + (opts.tx && txSelected.length > 0) || + (opts.unawaited && unawaitedFindings.length > 0); +process.exit(found ? 1 : 0); diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts new file mode 100644 index 000000000..f7f10c726 --- /dev/null +++ b/server/scripts/generate-entities.ts @@ -0,0 +1,2145 @@ +/** + * Re-runnable entity generator wrapper (ORM migration Plan 2, Task 2). + * + * `@mikro-orm/entity-generator` 7.2.1 already produces the Phase 0 shape + * (`Days`/`DayNotes`/`Trips`, see `docs/superpowers/specs/2026-09-21-orm-repository-migration-design.md` + * D1/D2) from the migrated schema once configured with a naming strategy whose + * `columnNameToProperty` is the identity — every scalar and FK twin comes out + * named exactly like its column. What it does NOT do on its own (verified by + * reading `node_modules/@mikro-orm/entity-generator/*.js` and by running this + * wrapper against the real schema, not by assumption): + * + * - it leaves multi-word relation names snake_case and inverse-collection + * names camelCase (the opposite of what Phase 0 does for each); + * - it never marks a relation `.hidden()`; + * - it emits `p.datetime()` / `p.unknown().columnType('REAL')` instead of + * `p.type(DbTimestampType)` / `p.double()`; + * - it leaves `updateRule('no action')` / `deleteRule('no action')` noise + * (SQLite reports "NO ACTION" for every FK that never named a rule); + * - integer autoincrement primary keys come out `.primary().nullable()` + * (SQLite's `PRAGMA table_info` carries no AUTOINCREMENT flag); + * - it never binds a `repository:`; + * - the "collision class" (an FK column with no `_id` suffix, e.g. + * `country`, `created_by`) gets a relation named after the column AND a + * same-named scalar twin — colliding — and even once renamed, the + * generator's own override mechanism for a to-one relation only ever + * emits `.name()`, which Task 0/1 proved is inert for a relation + * (`.joinColumn()` is the only thing that actually pins the FK + * column; see `.superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-0-report.md` + * and `task-1-report.md`, fact (c)). + * + * Each gap above is closed by a single named rule function, applied either at + * the metadata level (`onProcessedMetadata`, preferred — the MikroORM- + * documented hook for this exact purpose) or, only where the metadata cannot + * carry the fix into the rendered `defineEntity` call (traced against the + * actual renderer in `SourceFile.js`/`DefineEntitySourceFile.js`, not + * guessed), as a small text pass over the generated source. See + * `task-2-report.md` for which rule lives at which level and why. + * + * This script never writes into `src/db/entities/` on its own — `--write` is + * accepted (Task 3 uses it) but this task never passes it. It never opens + * `data/travel.db` either: it migrates a throwaway temp file (via the same + * `Migrator` extension `buildApp()` uses, configured directly rather than by + * importing `src/db/orm.ts` — that module transitively imports + * `src/db/database.ts`, which opens `resolveDbPath()` — i.e. the real + * database file — as a side effect of being imported at all). + * + * Usage: + * node --import tsx scripts/generate-entities.ts --out + * node --import tsx scripts/generate-entities.ts --write # Task 3 only + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + ReferenceKind, + UnderscoreNamingStrategy, + type Connection, + type EntityMetadata, + type EntityProperty, + type ImportsResolver, + type Platform, +} from '@mikro-orm/core'; +import { EntityGenerator } from '@mikro-orm/entity-generator'; +import { Migrator } from '@mikro-orm/migrations'; +import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite'; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** `server/` — every relative path below (migrations, repositories, entities) is anchored here. */ +const SERVER_ROOT = path.join(__dirname, '..'); +const ENTITIES_DIR = path.join(SERVER_ROOT, 'src/db/entities'); +const REPOSITORIES_DIR = path.join(SERVER_ROOT, 'src/db/repositories'); + +// --------------------------------------------------------------------------- +// Naming strategy — D1: scalars and FK twins are named exactly like their +// column (`day_id`, `sort_order`), never camelCased. Only `columnNameToProperty` +// is overridden; table names and everything else (`classToTableName`, +// `joinKeyColumnName`, `inverseSideName`, `manyToManyPropertyName`, …) stay the +// stock `UnderscoreNamingStrategy` behaviour, which is what lets the renamed +// relations below still compute the *same* FK column the schema already has +// (see RULE5_renameOwningRelations). +// --------------------------------------------------------------------------- +export class SnakeProps extends UnderscoreNamingStrategy { + override columnNameToProperty(column: string): string { + return column; + } +} + +// --------------------------------------------------------------------------- +// KNOWN_DIFFS — the only allowed differences between this wrapper's output +// (into a scratch dir) and the five hand-written reference entities +// (`Days`, `DayNotes`, `Trips`, `BudgetCategoryOrder`, `VacayUserSettings`). +// Anything else in the diff is a rule bug, not an entry to add here. +// --------------------------------------------------------------------------- +export const KNOWN_DIFFS = ` +1. Index duplicated onto the FK twin. The generator carries a relation's named + index (".index('idx_days_trip_id')") onto BOTH the owning relation AND its + persist(false) scalar twin; the hand-written files only ever declare it on + the relation. Harmless (SQLite would just create the same index once) — + verified against the actual generated output, not assumed. +2. Class-field TS annotation on an integer autoincrement primary key. The + hand-written files declare it "id?: number | null;" as an ergonomic hint + (you can build the entity before an id is assigned); the generator's own + renderer ties both the "?" and the "| null" suffix to metadata property + "nullable" (see SourceFile.js#getPropertyDefinition), and rule 3 below sets + nullable=false on this exact property so the metadata BUILDER CHAIN has no + stray ".nullable()" (matching the reference's *metadata*, which is what the + parity tests and the ORM itself read) — at the cost of the class field + rendering "id!: number & Opt;" instead (and the resulting "type Opt" import). + Purely a TypeScript-side annotation; it does not change runtime behaviour, + hydration, or the metadata the schema parity test compares. Confirmed by + generating the real schema, not assumed. It is also safe at the database + level despite the metadata's builder chain having no explicit + ".autoincrement()" call for this property: MikroORM's own + MetadataDiscovery#initAutoincrement (node_modules/@mikro-orm/core/metadata/MetadataDiscovery.js) + defaults "autoincrement ??= true" for any single numeric PK at discovery + time, so the ORM treats the generated and hand-written metadata + identically once loaded — confirmed by generating the real schema and + inserting through the written entity (Days.repository.test.ts's insert + passes). Task 3 should have its own parity test (a PARITY-010 or sibling) + asserting "autoincrement === true" in the *discovered* metadata for every + integer PK, so this inference is pinned rather than assumed going + forward. +3. Method-call order within a builder chain (e.g. ".deleteRule('cascade').hidden().index(...)" + vs the hand-written files' ".hidden().deleteRule('cascade').index(...)"). + The renderer assembles each property's options in its OWN fixed sequence + (kind-specific options — fieldName/deleteRule/updateRule/primary/generated — + then common flags — nullable/hidden/etc. — then index/unique), which the + hand-written files, authored before this generator produced this shape, did + not follow. Cosmetic only: every option present is the same, only the + textual order of independent chained calls differs. +4. "defaultRaw" renders as a backtick template literal ("\`CURRENT_TIMESTAMP\`") + rather than a single-quoted string ("'CURRENT_TIMESTAMP'"). SourceFile.js's + own renderer always uses a template literal for defaultRaw specifically (so + raw SQL containing "\${" is escaped rather than interpolated) — the + hand-written files predate this generator and used a plain string. + Semantically identical TypeScript. +5. A nullable column's DB-level default. Phase 0's own hand-written files are + not internally consistent about restating a NULLABLE column's default in + metadata at all: "DayNotes.sort_order" (nullable, "REAL DEFAULT 0") has + ".default(0)"; "DayNotes.icon" (nullable, "TEXT DEFAULT '📝'") has none + at all, relying only on the class-field initialiser. The wrapper's default + rule (RULE_normalizeLiteralDefaults) deliberately scopes itself to NOT + NULL columns only — see that rule's doc comment — and leaves whatever the + generator's own (occasionally "defaultRaw" instead of "default", or + entirely absent) rendering produces for a nullable column. This is exactly + Task 0's skipped PARITY-005 (column defaults) territory, not this task's. +6. Trailing blank line / whitespace at end of file, if any — a generator + formatting convention, immaterial to the parsed module. + +Fix round 1 (task-2-fix-brief.md) resolved the former #6 (the +VacayUserSettings.user_id PK-twin nullability judgment call) by taking the +generator's answer as correct and updating the hand-written file to match +(see .superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-2-review.md, +"#6 — generator or hand-written file?") — it is no longer a diff at all, so +it is no longer listed here. +`.trim(); + +// --------------------------------------------------------------------------- +// JSON columns — SQLite has no JSON storage class, so PRAGMA table_info can +// never tell the generator "this TEXT column is JSON-shaped"; that's always a +// judgement call an entity author made (today: exactly one column in the +// whole schema, `addons.config`, declared `p.json()`, grepped +// from src/db/entities). `JSON_COLUMNS` maps `table.column` -> the TypeScript +// type name to render (`p.json()`, never a bare `p.json()` or +// `p.json()` — Fix round 1, task-2-review.md C3: a stale +// `runtimeType` left the class field as `IType`, a new `any` in +// generated output). Every value here needs a matching declaration in +// `JSON_INTERFACES` below — `injectJsonInterfaces` throws otherwise. +// --------------------------------------------------------------------------- +export const JSON_COLUMNS: ReadonlyMap = new Map([['addons.config', 'AddonConfig']]); + +/** + * The interface declaration text `injectJsonInterfaces` inserts into a + * generated file for every `JSON_COLUMNS` type name it references. Not + * reconstructable from the schema (SQLite has no notion of a JSON column's + * shape) — this is a maintained list, the same shape as `JSON_COLUMNS` + * itself. `AddonConfig`'s shape is carried forward from the current + * hand-written `Addons.entity.ts`. + */ +const JSON_INTERFACES: Readonly> = { + AddonConfig: 'export interface AddonConfig {\n [key: string]: unknown;\n}\n', +}; + +// --------------------------------------------------------------------------- +// Boolean columns — same problem as JSON, the opposite direction: SQLite has +// no BOOLEAN storage class either (every "boolean" column is really +// INTEGER-affinity), so a plain `p.boolean()` column re-introspects as +// `p.integer()` and the class field silently degrades `boolean` -> `number` +// (Fix round 1, task-2-review.md I4). One column in the whole schema today +// (`addons.enabled`, grepped from src/db/entities — the only `p.boolean()` in +// the tree) — a maintained allow-list, same shape as `JSON_COLUMNS`. +// --------------------------------------------------------------------------- +export const BOOLEAN_COLUMNS: ReadonlySet = new Set(['addons.enabled']); + +// --------------------------------------------------------------------------- +// Rule 1 (metadata level): scalar type mapping. +// +// The naming strategy already makes every scalar/twin property name equal its +// column (no rule needed for that half of D1). What's left is the TYPE: the +// generator leaves a column it can't map cleanly as `p.unknown().columnType()`. +// On the current schema the only such family is SQLite's REAL affinity +// (MikroORM's SQLite platform never overrides `getDoubleDeclarationSQL`, so it +// stays abstract — confirmed in task-0-report.md while building the parity +// test's own normaliser). Anything else is unexpected: fail loudly, naming the +// column, rather than silently emitting a wrong or lossy type. +// --------------------------------------------------------------------------- +const UNKNOWN_COLUMN_TYPE_MAP: Readonly> = { + real: { type: 'double', runtimeType: 'number' }, +}; + +/** + * `prop.runtimeType` must move with `prop.type` here, not just the type name. + * Left stale (introspection sets it to `'unknown'` alongside `type: 'unknown'`), + * `SourceFile.js#breakdownOfIType` sees `prop.runtimeType !== rawType` (the + * freshly-retyped `'double'`'s own runtime, `'number'`) and treats that + * mismatch as genuine type ambiguity — it wraps the class field in + * `IType` and, separately, `needsExplicitColumnType()` also + * disagrees with itself and adds a redundant `.columnType('REAL')`. Both + * disappear once `runtimeType` is corrected. Confirmed by generating the real + * schema (`DayNotes.sort_order`) before and after this fix, not assumed. + */ +/** A scalar this run retyped (Rule 1's `p.unknown()` fix or Rule 2's `DbTimestampType`) — its metadata line may carry a redundant `.columnType()` (see below). */ +export interface RetypedScalarFixup { + className: string; + propName: string; +} + +export function RULE1_fixUnknownScalarTypes(metadata: EntityMetadata[]): RetypedScalarFixup[] { + const fixups: RetypedScalarFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (prop.type !== 'unknown') continue; + const columnType = (prop.columnTypes?.[0] ?? '').toLowerCase(); + const mapped = UNKNOWN_COLUMN_TYPE_MAP[columnType]; + if (!mapped) { + throw new Error( + `generate-entities: ${meta.tableName}.${prop.fieldNames.join(',')} has an unmapped column type ` + + `"${prop.columnTypes?.[0]}" (property "${meta.className}.${prop.name}"). Add it to ` + + 'UNKNOWN_COLUMN_TYPE_MAP once you know what it should render as — never guess.', + ); + } + prop.type = mapped.type; + prop.runtimeType = mapped.runtimeType; + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +/** + * A JSON column this run marked — like `RetypedScalarFixup` (it needs the + * same `.columnType()` strip and is folded into `retypedScalars`) plus the + * TypeScript type name to render. + */ +export interface JsonColumnFixup extends RetypedScalarFixup { + typeName: string; +} + +/** + * Rule 1b (metadata level): mark known JSON columns so they render + * `p.json()` (see JSON_COLUMNS above). `prop.runtimeType` must move with + * `prop.type` here for the same reason Rule 1's doc comment gives — left at + * its introspected value (`'string'`, since SQLite stores JSON as TEXT), the + * mismatch against `JsonType`'s own always-`'any'` runtime type would still + * make `SourceFile.js#breakdownOfIType` treat the property as ambiguous and + * wrap the class field in `IType<..., any>`. `fixJsonClassFieldTypes` (a text + * pass, see below) closes what setting `runtimeType` here does not: MikroORM + * has no metadata escape from `IType` for a JSON-typed property at + * all (`JsonType#runtimeType` is a hardcoded getter, not derived from the + * property) — confirmed by generating the real schema and reading + * `JsonType.js`, not assumed. + */ +export function RULE1b_markJsonColumns(metadata: EntityMetadata[]): JsonColumnFixup[] { + const fixups: JsonColumnFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + const typeName = JSON_COLUMNS.get(`${meta.tableName}.${prop.fieldNames[0]}`); + if (!typeName) continue; + prop.type = 'json'; + prop.runtimeType = typeName; + fixups.push({ className: meta.className, propName: prop.name, typeName }); + } + } + return fixups; +} + +/** Rule 1c (metadata level): mark known boolean columns so they render `p.boolean()` (see BOOLEAN_COLUMNS above). */ +export function RULE1c_markBooleanColumns(metadata: EntityMetadata[]): RetypedScalarFixup[] { + const fixups: RetypedScalarFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (!BOOLEAN_COLUMNS.has(`${meta.tableName}.${prop.fieldNames[0]}`)) continue; + prop.type = 'boolean'; + // `runtimeType` must move with `type` here too, for the same reason + // Rule 1's doc comment gives: left at its introspected value + // (`'number'`, SQLite has no BOOLEAN storage class), the mismatch + // against `BooleanType`'s own runtime type ('boolean') would make + // `breakdownOfIType` treat the property as ambiguous and wrap the + // class field in `IType` — confirmed by generating + // the real schema before this fix, not assumed. + prop.runtimeType = 'boolean'; + // The introspected DB default is always the raw SQLite value (`0`/`1`, + // a number, since SQLite has no boolean storage class either) — + // coerce it to a real boolean so a `p.boolean()` column's `.default()` + // renders `.default(false)`, not the now ill-typed `.default(0)`. + if (typeof prop.default === 'number') prop.default = prop.default !== 0; + if (typeof prop.defaultRaw === 'string' && /^-?\d+$/.test(prop.defaultRaw)) { + prop.defaultRaw = prop.defaultRaw !== '0' ? 'true' : 'false'; + } + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +/** + * Rule 1d (metadata level): a numeric scalar's class-field initialiser must + * never render `NaN`. + * + * `@mikro-orm/sql`'s own introspection (`DatabaseTable.js#getPropertyDeclaration` + * -> `getPropertyDefaultValue`) computes a NUMERIC column's `prop.default` as + * `+defaultValue` — a plain unary-plus coercion of the raw SQLite default + * text — whenever that text doesn't equal the literal, lowercase string + * `'null'`. SQLite reports the DDL's exact casing back through + * `PRAGMA table_info` (`'NULL'`, not `'null'`, for `DEFAULT NULL`), and + * `SchemaHelper#normalizeDefaultValue`'s own `'null'` check + * (`DatabaseTable.js` line ~869) is the same case-sensitive comparison — so + * neither the `DEFAULT NULL` case NOR a non-literal expression default + * (`DEFAULT (strftime('%s','now'))`) is ever recognised as "no usable + * numeric value", and `+'NULL'` / `+"(strftime('%s','now'))"` both evaluate + * to `NaN`. That `NaN` becomes `prop.default`, which + * `SourceFile.js#getPropertyDefinition` then treats as a genuine, usable + * default (`NaN` is neither `undefined` nor `null`) and renders `= NaN` as + * the class-field initialiser — confirmed by generating the real schema + * (`BudgetItems.persons`/`.days`, `IdempotencyKeys.created_at`, + * `BudgetItems.place_id`/`.reservation_id`, `PackingBags.user_id`) and by + * reading `node_modules/@mikro-orm/sql/schema/DatabaseTable.js`, not + * assumed. `prop.defaultRaw` itself is unaffected — it is threaded straight + * through to `.defaultRaw(...)` in the rendered schema and is correct there; + * only the class-field initialiser is wrong, and it is wrong specifically + * because the upstream library's `NaN` looks like "a real number" to the + * renderer's own `useDefault` check. + * + * Clearing `prop.default` back to `undefined` (never `null` — `null` would + * make the renderer's `hasUsableNullDefault` branch fire and print `= null`, + * which is still an initialiser this rule must not emit) makes the + * renderer's `useDefault` check naturally false, so NO initialiser is + * emitted at all: the class field falls back to nullability alone — + * `?: number | null` for a nullable column, `!: number` for a NOT NULL one + * (matching every other scalar's fallback shape in this generator). + * + * A `defaultRaw` that IS a finite numeric literal (`'0'`, `'1'`) is left + * completely alone: `Number(raw)` is correct there, `prop.default` already + * holds the right value, and nothing needs fixing. + */ +export function RULE1d_fixNaNNumericDefaults(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (typeof prop.defaultRaw !== 'string') continue; + if (Number.isFinite(Number(prop.defaultRaw))) continue; // a genuine numeric literal — untouched + if (typeof prop.default !== 'number' || !Number.isNaN(prop.default)) continue; // nothing broken here + prop.default = undefined; + } + } +} + +// --------------------------------------------------------------------------- +// Rule 2 (metadata level): p.datetime() -> p.type(DbTimestampType). +// +// `prop.type = 'DbTimestampType'` (a plain string that is NOT a key in +// @mikro-orm/core's `types` registry) makes the renderer emit `p.type(DbTimestampType)` +// verbatim (DefineEntitySourceFile#getPropertyBuilder: `!(options.type in types)` +// -> `p.type(${options.type})`), and it auto-adds 'DbTimestampType' to the +// file's entityImports (SourceFile#getScalarPropertyDecoratorOptions, +// isTypeStringMissingFromMap branch) — the IMPORT PATH for that identifier +// (`../types`) is resolved by the onImport hook below, also metadata/config +// level, not a text pass. `.nullable()`/`.defaultRaw(...)` come from the +// already-introspected `prop.nullable`/`prop.defaultRaw` and need no rule; +// introspection never invents an `onCreate` hook (there is nothing in a +// PRAGMA to discover one from), so "never onCreate" needs no enforcement here. +// --------------------------------------------------------------------------- +/** A scalar this run retyped to `DbTimestampType` — the class field's type still needs a text fix (see below). */ +export type TimestampFixup = RetypedScalarFixup; + +export function RULE2_datetimeToDbTimestampType(metadata: EntityMetadata[]): TimestampFixup[] { + const fixups: TimestampFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (prop.type !== 'datetime') continue; + prop.type = 'DbTimestampType'; + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +// --------------------------------------------------------------------------- +// Rule 3 (metadata level): integer `id` PK -> p.integer().primary().autoincrement(). +// +// All 122 integer PKs in the migrations are AUTOINCREMENT (inputs.md), but +// SQLite's PRAGMA table_info carries no such flag, so the generator always +// emits `.primary().nullable()` for one. `nullable = false` is what removes +// the stray `.nullable()` from the metadata BUILDER CHAIN (SourceFile's +// getCommonDecoratorOptions emits `.nullable()` whenever `prop.nullable` is +// true, unconditionally) — the one place this creates a *textual*, not +// semantic, difference from the hand-written files is documented as +// KNOWN_DIFFS #2 above. Composite and text PKs are untouched, per the plan +// ("`.nullable()` on text PKs is parity-correct — keep"). +// --------------------------------------------------------------------------- +export function RULE3_integerPkAutoincrement(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + if (meta.getPrimaryProps().length !== 1) continue; // composite PK — untouched + const [pk] = meta.getPrimaryProps(); + if (pk.kind !== undefined && pk.kind !== ReferenceKind.SCALAR) continue; // a relation PK — untouched + const columnType = (pk.columnTypes?.[0] ?? '').toLowerCase(); + if (columnType !== 'integer') continue; // a text PK — untouched + pk.autoincrement = true; + pk.nullable = false; + } +} + +// --------------------------------------------------------------------------- +// Rule 4 (metadata level): every relation, owning and inverse, any kind, hidden(). +// --------------------------------------------------------------------------- +export function RULE4_hideAllRelations(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of meta.relations) { + prop.hidden = true; + } + } +} + +function isOwningToOne(prop: EntityProperty): boolean { + if (prop.kind === ReferenceKind.MANY_TO_ONE) return true; + return prop.kind === ReferenceKind.ONE_TO_ONE && !prop.mappedBy; +} + +function isInverseSide(prop: EntityProperty): boolean { + if (prop.kind === ReferenceKind.ONE_TO_MANY) return true; + if (prop.kind === ReferenceKind.ONE_TO_ONE && !!prop.mappedBy) return true; + if (prop.kind === ReferenceKind.MANY_TO_MANY && !!prop.mappedBy) return true; + return false; +} + +function toCamelCase(snake: string): string { + return snake.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); +} + +function toSnakeCase(camel: string): string { + return camel.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + +/** A collision-class relation this run renamed: needs an explicit `.joinColumn()` text fix. */ +export interface JoinColumnFixup { + className: string; + propName: string; + column: string; +} + +/** + * Renames don't just need `properties[newName] = prop` (what `addProperty` + * does) — `EntityGenerator`'s `DefineEntitySourceFile` renders the + * `properties: {...}` object by iterating `Object.entries(meta.properties)` + * directly (not the sorted `meta.props`/`propertyOrder` MikroORM otherwise + * uses), so `removeProperty` + `addProperty`'s delete-then-reinsert always + * moves a renamed property to the END of the file (Fix round 1, + * task-2-review.md M7). This rebuilds the dict's key order to match + * `originalOrder` (captured before any renames), substituting each renamed + * key for its old one — same prop objects, just re-keyed and reordered in + * place, so `propertyOrder`/`props`/`relations` (already correct from + * `addProperty`'s own `sync()`) are untouched. + */ +function restorePropertyOrder(meta: EntityMetadata, originalOrder: readonly string[], renameMap: ReadonlyMap): void { + const keyOrder = originalOrder.map((oldName) => renameMap.get(oldName) ?? oldName); + const values = keyOrder.map((key) => meta.properties[key]); + for (const key of keyOrder) delete meta.properties[key]; + keyOrder.forEach((key, i) => { + meta.properties[key] = values[i]; + }); +} + +/** Stock `UnderscoreNamingStrategy` behaviour (our `SnakeProps` override never touches it) — used by RULE5 to verify a rename's round trip (see I2 below). No state, safe to share. */ +const namingStrategy = new SnakeProps(); + +/** + * Rule 5 (metadata level for the rename; text level for the join column — see + * task-2-report.md "rule 5"): owning relation property name = camelCase of + * (column minus a trailing `_id`). A "collision class" column (no `_id` + * suffix: `created_by`, `country`, … — the FK twin would otherwise be named + * identically to the relation) gets `Ref` instead, and — because the + * renderer for a to-one relation only ever knows how to emit `.name()` + * as an override, which Task 0/1 proved does not actually pin the FK column — + * this function also records the (className, newPropName, column) triple for + * `injectJoinColumns` to turn into an explicit `.joinColumn()` in the + * generated text afterwards. + * + * A multi-column FK (`prop.fieldNames.length !== 1`) throws rather than + * silently keeping the raw snake_case name (Fix round 1, task-2-review.md + * I1 — the schema has zero of these today; the plan's D1 camelCase-relations + * rule has no shape for one, and nothing else would catch the drift, since + * `fieldNames` themselves stay correct). + * + * For a NORMAL (non-collision-class) rename, this also verifies the round + * trip: `namingStrategy.joinKeyColumnName(newName, referencedColumn)` must + * reproduce the real `column`, or the renderer's implicit FK-column inference + * (nothing pins it explicitly for this case) would silently point at the + * wrong column the moment a future migration's naming breaks the assumption + * (Fix round 1, task-2-review.md I2). A mismatch gets the exact same + * `JoinColumnFixup` treatment as a collision class — no separate code path. + * + * Every inverse `mappedBy` across the whole metadata array that pointed at + * the OLD owning name is fixed up to the new one in the same pass (mappedBy + * is a plain string on the inverse side; nothing else references an owning + * relation by name). + */ +export function RULE5_renameOwningRelations(metadata: EntityMetadata[]): JoinColumnFixup[] { + const fixups: JoinColumnFixup[] = []; + const renames = new Map>(); // meta -> oldName -> newName + + for (const meta of metadata) { + const originalOrder = Object.keys(meta.properties); + const renameMap = new Map(); + for (const prop of [...meta.relations]) { + if (!isOwningToOne(prop)) continue; + if (prop.fieldNames.length !== 1) { + throw new Error( + `generate-entities: ${meta.className}.${prop.name} is a multi-column FK ` + + `(${prop.fieldNames.join(', ')}) — RULE5 only knows how to rename a single-column owning ` + + 'relation. Add explicit handling for this relation before regenerating this entity.', + ); + } + const column = prop.fieldNames[0]; + const isCollisionClass = !column.endsWith('_id'); + const stripped = isCollisionClass ? column : column.slice(0, -'_id'.length); + const newName = isCollisionClass ? `${toCamelCase(stripped)}Ref` : toCamelCase(stripped); + if (newName === prop.name) continue; + if (meta.properties[newName]) { + throw new Error( + `generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` + + `existing property of that name. Column: ${column}.`, + ); + } + const oldName = prop.name; + meta.removeProperty(oldName, false); + prop.name = newName; + meta.addProperty(prop); + renameMap.set(oldName, newName); + if (isCollisionClass) { + fixups.push({ className: meta.className, propName: newName, column }); + } else { + const referencedColumn = prop.referencedColumnNames?.[0]; + const derivedColumn = namingStrategy.joinKeyColumnName(newName, referencedColumn); + if (derivedColumn !== column) { + fixups.push({ className: meta.className, propName: newName, column }); + } + } + } + if (renameMap.size > 0) { + renames.set(meta, renameMap); + restorePropertyOrder(meta, originalOrder, renameMap); + } + } + + // Fix up every inverse `mappedBy` that pointed at a renamed owning property. + for (const meta of metadata) { + for (const prop of meta.relations) { + if (!prop.mappedBy) continue; + const targetMeta = metadata.find((m) => m.className === prop.type); + if (!targetMeta) continue; + const renameMap = renames.get(targetMeta); + const renamed = renameMap?.get(prop.mappedBy); + if (renamed) prop.mappedBy = renamed; + } + } + + return fixups; +} + +/** + * Rule 6 (metadata level): inverse collections and inverse one-to-ones are + * named snake_case of whatever `bidirectionalRelations` generated (always + * camelCase, from `namingStrategy.inverseSideName`, a naming-strategy method + * our `SnakeProps` override does not touch) — matches Phase 0 + * (`day_notes_collection`). Pure case conversion: `dayAccommodationsCollection1` + * -> `day_accommodations_collection1` (the disambiguating digit the generator + * itself appends on a naming collision, e.g. two FKs from the same table, + * survives untouched — the regex only inserts an underscore before an + * uppercase letter, never before a digit). + */ +export function RULE6_renameInverseCollections(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + const originalOrder = Object.keys(meta.properties); + const renameMap = new Map(); + for (const prop of [...meta.relations]) { + if (!isInverseSide(prop)) continue; + const newName = toSnakeCase(prop.name); + if (newName === prop.name) continue; + if (meta.properties[newName]) { + throw new Error( + `generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` + + 'existing property of that name.', + ); + } + const oldName = prop.name; + meta.removeProperty(oldName, false); + prop.name = newName; + meta.addProperty(prop); + renameMap.set(oldName, newName); + } + // Same rendering-order fix as RULE5 (see `restorePropertyOrder`'s doc comment) — M7. + if (renameMap.size > 0) restorePropertyOrder(meta, originalOrder, renameMap); + } +} + +/** + * Rule 7 (metadata level): drop `updateRule('no action')` / `deleteRule('no action')` + * noise. SQLite's `PRAGMA foreign_key_list` reports "NO ACTION" for every FK + * that never named an ON UPDATE/ON DELETE clause — that's "no information", + * not a real rule the migration stated, so the generator's raw introspection + * carries it as noise on nearly every relation. Every other value (cascade, + * set null, restrict) is real and kept. + */ +export function RULE7_dropNoActionRules(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of meta.relations) { + if (prop.updateRule?.toLowerCase() === 'no action') delete prop.updateRule; + if (prop.deleteRule?.toLowerCase() === 'no action') delete prop.deleteRule; + } + } +} + +/** + * Rule 8 (metadata level): `repository: () => XRepository` + its import. + * Setting `meta.repositoryClass` is the whole rule — SourceFile's + * getEntityDeclOptions() (shared by defineEntity/entitySchema output) turns a + * truthy `repositoryClass` into both the `repository: () => X` option AND an + * auto-added import for that identifier; the onImport hook below resolves it + * to `../repositories/X.repository`. Whether the *file* on disk actually gets + * written (only when missing, never overwritten) is write-mode's job in + * `writeRepositoriesIfMissing`, not this metadata rule. + */ +export function RULE8_bindRepositories(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + meta.repositoryClass = `${meta.className}Repository`; + } +} + +/** + * Rule 9 (metadata level; DB-dependent — see `collectImplicitUniqueIndexes` + * below for the half that actually queries the schema): every column-set an + * inline `UNIQUE(...)` table constraint (or a column-level `UNIQUE`) names + * gets a matching `uniques: [{ properties: [...] }]` entry. + * + * Why this is needed at all: SQLite backs every such constraint with an + * unnamed `sqlite_autoindex__` index, and `@mikro-orm/sql`'s own + * `SqliteSchemaHelper.getIndexes()` calls `isImplicitIndex(name)` — true for + * any index whose name starts with `sqlite_` — to filter exactly these out + * before `entity-generator` ever sees them (read directly out of + * `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not + * assumed): "Ignore indexes with reserved names, e.g. autoindexes". A NAMED + * `CREATE UNIQUE INDEX` (`Trips.idx_trips_feed_token`, `Reservations. + * idx_reservations_external`, …) survives that filter and already renders + * correctly with no help from this rule — only the inline/unnamed spelling is + * silently dropped. `settings`'s `UNIQUE(user_id, key)` is the ruling's + * trigger case (metadata FIDELITY for the constraint `em.upsert`'s conflict + * target is documented against — see below for what that entry does and + * does not buy), but the same gap exists on ~40 other tables in the schema + * (grep `UNIQUE(` across `db/migrations/*.ts`), so this rule is general — + * every table, not just `settings` — and this task's report says which + * entities' `uniques:` this adds where none existed in the hand-written + * baseline before. + * + * `implicitUniques` is precomputed once per generator run and handed in as + * plain data (table name -> one string[] of column names per implicit unique + * index) specifically so this rule itself stays a pure, fixture-testable + * function like every other rule in this file — it never opens a connection + * itself. + * + * A column that IS a single-column FK's join column (the `persist(false)` + * twin's column, e.g. `settings.user_id`) is emitted as the RELATION + * property name (`user`), never the twin (`user_id`) — looked up from + * `meta.relations` (already renamed by RULE5 by the time this rule runs, so + * this sees the final, post-rename property name) by matching an owning + * to-one relation's single `fieldNames[0]` against the column. Every other + * column doubles as its own property name with no naming-strategy lookup + * (`SnakeProps.columnNameToProperty` is the identity, D1). + * + * This is a deliberate DIVERGENCE from the pre-existing hand-written/ + * generated composite `uniques:` blocks elsewhere in the schema + * (`Reservations`/`DocumentSyncItems`/`TrekPhotos`/`FileLinks` all reference + * the twin — `['file_id', 'budget_item_id']`, never `['file', 'budgetItem']` + * — because those are NAMED `CREATE UNIQUE INDEX`es MikroORM's own + * introspection already renders, untouched by this rule, and it always uses + * column names there). The reason for the divergence: unlike the twin + * spelling, the relation-property spelling is actually consumable by + * `em.upsert`'s own conflict-target inference — `getWhereCondition` in + * `@mikro-orm/core/utils/upsert-utils.js` matches a `uniques` entry's + * `properties` against the upsert payload's own keys, which are PROPERTY + * names (`data.user`, never `data.user_id`, since nobody passes a + * `persist(false)` twin into `em.upsert`'s data). Proven directly: + * `Settings.repository.test.ts`'s SETTINGSREPO-014 calls `em.upsert` on + * `Settings` with no `onConflictFields` at all and still gets + * `on conflict (\`user_id\`, \`key\`)`. + * + * That said, this rule's job is METADATA FIDELITY — the generated entity + * matching what the migrated schema actually constrains — not a functional + * dependency any caller has on it: `SettingsRepository.upsertForUser` keeps + * its `onConflictFields: ['user', 'key']` explicit regardless (clearer at + * the call site than relying on inference from metadata), and every other + * `uniques:` entry this rule adds has no `em.upsert` caller at all yet. A + * column an implicit index names that has no matching property AND no + * matching relation twin (naming-strategy mismatch, or a typo in the + * migration) throws, naming the table/columns/entity, rather than silently + * emitting a `uniques:` entry the ORM would reject at discovery time. + * + * A column set that is exactly the table's own primary key is skipped: a + * non-`INTEGER` (or composite) PRIMARY KEY gets the identical kind of + * unnamed `sqlite_autoindex_` entry a table-level `UNIQUE(...)` does + * (`AppSettings.key`, `Addons.id` — both `TEXT PRIMARY KEY`, confirmed by + * running this rule unfiltered against the real schema before adding this + * check), and it is already unique by construction and already `.primary()` + * on every one of its columns — a `uniques:` entry for it is pure noise, not + * a second constraint. + */ +export function RULE9_addImplicitUniqueConstraints( + metadata: EntityMetadata[], + implicitUniques: ReadonlyMap, +): void { + for (const meta of metadata) { + const indexes = implicitUniques.get(meta.tableName); + if (!indexes) continue; + const primaryKeys = new Set(meta.primaryKeys); + // A single-column owning to-one relation's join column, keyed by that + // column so a FK twin (`user_id`) resolves to the relation's own + // (post-RULE5-rename) property name (`user`) — see the doc comment above + // for why this, rather than the twin, is what gets emitted. + const relationNameByColumn = new Map(); + for (const prop of meta.relations) { + if (!isOwningToOne(prop) || prop.fieldNames.length !== 1) continue; + relationNameByColumn.set(prop.fieldNames[0], prop.name); + } + for (const columns of indexes) { + if (columns.length === primaryKeys.size && columns.every((column) => primaryKeys.has(column))) continue; + const properties = columns.map((column) => { + const relationName = relationNameByColumn.get(column); + if (relationName) return relationName; + if (!meta.properties[column]) { + throw new Error( + `generate-entities: implicit unique index on ${meta.tableName}(${columns.join(', ')}) references ` + + `column "${column}", which has no matching property on ${meta.className} (naming-strategy mismatch?).`, + ); + } + return column; + }); + meta.uniques.push({ properties }); + } + } +} + +/** A repository-type marker to inject into one entity class — Rule 10. */ +export interface RepositoryTypeMarkerFixup { + className: string; + repositoryClassName: string; +} + +/** + * Rule 10 (metadata level: which class needs a marker and what to name it; + * text level: the actual insertion, see `injectRepositoryTypeMarker` below — + * this split, and why it cannot be a pure metadata rule, mirrors Rule 5's + * `.joinColumn()` injection): every entity class gets + * `[EntityRepositoryType]?: XRepository;` as its FIRST member, so + * `em.getRepository(X)` — and `t.repo(X)` in `tests/helpers/test-orm.ts`, + * whose `repo` already returns `GetRepository>` — + * resolves to the entity's own repository type instead of the generic + * `SqlEntityRepository` fallback. `GetRepository` + * (`@mikro-orm/core`'s `typings.d.ts`, installed version 7.2.1) reads + * exactly this symbol-keyed member off the entity type + * (`Entity[typeof EntityRepositoryType]`) — confirmed against the installed + * typings, not assumed. + * + * `@mikro-orm/entity-generator`'s OWN renderer already knows how to emit this + * marker, but only for the decorator-based `@Entity()` shape + * (`SourceFile.js#generate()`, guarded by `if (this.meta.repositoryClass)`). + * Our `defineEntity` output goes through a different method entirely — + * `EntitySchemaSourceFile.generateClassDefinition()` (shared by + * `DefineEntitySourceFile`, read directly out of + * `node_modules/@mikro-orm/entity-generator/EntitySchemaSourceFile.js`) — + * which only ever consults `PrimaryKeyProp`/`EagerProps`/`Config` for the + * class head, never `repositoryClass`. No `GenerateOptions` flag changes + * that, so — exactly like Rule 5's join column — the metadata alone cannot + * carry this into the rendered class body; only a text pass can. + * + * The metadata half below does not mutate anything: `meta.repositoryClass` + * is already set for every entity by Rule 8 (which always runs first, see + * `applyRules`) — this is purely a collector, so the text pass has + * (className, repositoryClassName) pairs to work from without re-deriving + * the "Repository" naming convention a second time. + * + * The marker is a type-only declaration (`?:`, never assigned a value) — NOT + * a real entity property. It must never go through `meta.addProperty`: + * MikroORM would then treat it as a genuine, unmapped column. + * `tests/unit/db/entity-class-fields.test.ts` (CLASSFIELD-003) pins that this + * stays true — the marker never shows up in `meta.props` and is never + * counted by CLASSFIELD-002's class-field census. + */ +export function RULE10_repositoryTypeMarker(metadata: EntityMetadata[]): RepositoryTypeMarkerFixup[] { + const fixups: RepositoryTypeMarkerFixup[] = []; + for (const meta of metadata) { + if (!meta.repositoryClass) continue; // Rule 8 always sets this — defensive only, never observed empty + fixups.push({ className: meta.className, repositoryClassName: meta.repositoryClass }); + } + return fixups; +} + +/** An owning relation whose FK targets a non-PK column on its target entity — Rule 11. */ +export interface ReferencedColumnsFixup { + className: string; + propName: string; + referencedColumnNames: readonly string[]; +} + +/** + * Rule 11 (metadata level: detection only, no mutation; text level: the + * actual insertion — see `injectReferencedColumns` below. This split, and why + * it cannot be a pure metadata rule, mirrors Rule 5's `.joinColumn()` + * injection and Rule 10's repository marker. + * + * `@mikro-orm/sql`'s SQLite introspection (`SqliteSchemaHelper.js`, straight + * from `PRAGMA foreign_key_list`'s `to` column) already sets + * `prop.referencedColumnNames` correctly for every owning to-one relation — + * confirmed empirically with a throwaway `onProcessedMetadata` probe against + * the real migrated schema, BEFORE any rule in this file runs: + * `OauthTokens.client`/`OauthConsents.client` already carry + * `referencedColumnNames: ['client_id']` (the schema's real FK target, + * `Migration20200101012500_oauth_2.ts`'s `REFERENCES oauth_clients(client_id)` + * — a UNIQUE natural key, not `OauthClients`'s PK, `id`), while every other + * owning relation in the whole schema (`OauthTokens.user`, `.parentToken`, + * `SchoolHolidayRegions.countryRef` -> `school_holiday_countries(code)`, + * which IS that table's PK, …) already carries `referencedColumnNames` equal + * to its target's own PK. + * + * The generator's own RENDERER never emits this correct, already-introspected + * fact into the generated text, though — it is not something an earlier rule + * in this file drops. Read directly out of + * `node_modules/@mikro-orm/entity-generator/{EntityGenerator,SourceFile}.js`, + * not assumed: `SourceFile.js#getForeignKeyDecoratorOptions` (shared by every + * output mode, including `defineEntity`, through + * `EntitySchemaSourceFile.getPropertyOptions` -> `DefineEntitySourceFile`) + * only ever emits `.referencedColumnNames([...])` when + * `prop.ownColumns && prop.ownColumns.length !== prop.fieldNames.length` — a + * COMPOSITE-FK partial-ownership column-COUNT mismatch, never a check of + * whether the referenced column differs from the target's PK. `prop.ownColumns` + * itself is only ever set by `EntityGenerator.js#generate()` when + * `targetPrimaryColumns.length !== prop.referencedColumnNames.length` — again + * a length comparison, not a content comparison. For a single-column FK + * against a single-column PK (exactly the `client_id` case here), both + * lengths are 1 either way, so `ownColumns` is never set and the renderer's + * emission check never fires, no matter which column is actually referenced. + * This is a real gap in `@mikro-orm/entity-generator` 7.2.1's own renderer, + * not a defect our earlier text passes introduce or could avoid at the + * metadata level. + * + * Detection compares `prop.referencedColumnNames` (already correct) against + * the target entity's OWN primary-key field names + * (`targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames)`) — never a + * hard-coded table list, so a future migration adding another FK against a + * non-PK column is caught automatically the next time this generator runs, + * not silently missed. + */ +export function RULE11_referencedColumns(metadata: EntityMetadata[]): ReferencedColumnsFixup[] { + const fixups: ReferencedColumnsFixup[] = []; + for (const meta of metadata) { + for (const prop of meta.relations) { + if (!isOwningToOne(prop)) continue; + const targetMeta = metadata.find((m) => m.className === prop.type); + if (!targetMeta) continue; // unresolved relation target — nothing this rule can compare against + const targetPk = targetMeta.getPrimaryProps().flatMap((pk) => pk.fieldNames); + const referenced = prop.referencedColumnNames ?? []; + const matchesTargetPk = referenced.length === targetPk.length && referenced.every((column, i) => column === targetPk[i]); + if (matchesTargetPk) continue; + fixups.push({ className: meta.className, propName: prop.name, referencedColumnNames: [...referenced] }); + } + } + return fixups; +} + +/** A CHECK constraint this run repaired — its introspected expression had swallowed trailing DDL text. */ +export interface CheckExpressionFixup { + className: string; + checkName: string | undefined; +} + +/** + * RULE12 (metadata level): repair a CHECK expression that MikroORM's own + * SQLite introspection over-captured from a trailing table-level constraint + * (Plan 3d Task 0, R10 — `roadtrip_day_boundaries.fraction`). + * + * `SqliteSchemaHelper#getChecks` (verified directly against + * `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not + * assumed) extracts a column's inline CHECK with `/ (check \((.*)\))/i` + * against that column's own slice of `PRAGMA table_xinfo`'s definition text. + * The regex's greedy `.*` runs to the LAST `)` in that slice — correct when + * the checked column is followed only by other columns, wrong when it is the + * last column before a table-level constraint with its own parens. The + * migration (`Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts`) + * declares exactly that shape: + * + * fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + * PRIMARY KEY (trip_id, day_number) + * + * — so the introspected expression comes back as + * `"fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number"`, + * not the DDL's `"fraction BETWEEN 0 AND 1"`. This is a third-party + * introspection defect (the migration's own DDL is correct, and + * `day_number`'s sibling CHECK on the same table — not the last column + * before the table constraint — introspects cleanly), so it is repaired + * here rather than by touching the migration or hand-editing the entity. + * + * The fix walks the captured expression tracking parenthesis depth, + * starting at 1 (the string has already had its own OUTER, matching + * `CHECK (...)` parens stripped by the upstream regex): `(` increments, + * `)` decrements. The first time depth returns to 0 is where the check's + * OWN expression actually closes — the DDL's CHECK is, by definition, + * itself a balanced parenthesised expression, so that is exactly its end; + * everything captured after it is the swallowed trailing constraint text, + * discarded. An expression whose parens never rebalance to 0 before the + * string ends (no parens at all — `day_number >= 1`, + * `end_date >= start_date` — or balanced-but-never-closing nested ones) is + * left byte-identical: this only fires on the specific over-capture shape, + * never on a clean expression. + * + * The walk skips a `(`/`)` that falls inside a single-quoted SQL string + * literal (a doubled `''` is SQLite's escape for a literal quote *inside* + * the literal, so it does not end the string) — an expression like + * `status IN ('confirmed)', 'tentative')` has a literal `)` inside its first + * string value that is not a real closing paren; without this tracking the + * scanner would hit depth 0 at that spurious character and truncate a + * perfectly clean expression (losing its own genuine trailing `)` in the + * process, corrupting otherwise-valid SQL), not just leave RULE12-003's kind + * of case ambiguous the way a false paren-count fires here (Task 8a's own + * carry — "nothing affected today" per 3d's ledger, a latent-bug fix, not an + * active one — no shipped entity's CHECK expression has hit this shape yet). + */ +export function RULE12_fixGarbledCheckExpressions(metadata: EntityMetadata[]): CheckExpressionFixup[] { + const fixups: CheckExpressionFixup[] = []; + for (const meta of metadata) { + for (const check of meta.checks) { + if (typeof check.expression !== 'string') continue; + const expr = check.expression; + let depth = 1; + let cut = -1; + let inString = false; + for (let i = 0; i < expr.length; i++) { + const ch = expr[i]; + if (inString) { + if (ch === "'") { + if (expr[i + 1] === "'") { + i++; // a doubled '' is an escaped quote inside the literal, not its end + continue; + } + inString = false; + } + continue; + } + if (ch === "'") { + inString = true; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) { + cut = i; + break; + } + } + } + if (cut === -1) continue; // parens never rebalance to 0 — a clean expression, nothing swallowed + const fixed = expr.slice(0, cut).trimEnd(); + if (fixed === expr) continue; + check.expression = fixed; + fixups.push({ className: meta.className, checkName: check.name }); + } + } + return fixups; +} + +/** A relation whose `deleteRule` RULE13 had to pin explicitly because MikroORM's own runtime default would otherwise disagree with `PRAGMA foreign_key_list`. */ +export interface DeleteRuleFixup { + className: string; + propName: string; + from: string | undefined; + to: string; +} + +/** + * RULE13 (metadata level; DB-dependent — see `collectForeignKeyDeleteRules` + * below for the half that queries the schema): every owning to-one + * relation's EFFECTIVE `deleteRule` — explicit if the entity sets one, + * otherwise whatever `@mikro-orm/core`'s `MetadataDiscovery` infers by + * default at runtime — must equal `PRAGMA foreign_key_list`'s `on_delete` + * for its column. Plan 4 Task 7's own measurement + * (`tests/unit/db/entity-schema-parity.test.ts`, PARITY-009b) found exactly + * 10 owning relations across 120 entities where it does not. + * + * The root cause, read directly out of + * `node_modules/@mikro-orm/core/metadata/MetadataDiscovery.js` (not + * assumed): a nullable owning relation defaults to `deleteRule ??= 'set + * null'` (`initManyToOneFields`/`initOneToOneFields`); separately, when + * EVERY one of an entity's primary keys is itself a relation (composite or + * single FK-as-PK), each of those relations defaults to `deleteRule ??= + * 'cascade'` (`processEntity`'s `fkPks` block) — and the nullable check runs + * FIRST, so a relation that is both nullable and part of an all-FK PK still + * gets `'set null'`, never `'cascade'`. Neither default is announced by the + * generated source when the introspected value happens to equal it — Rule 7 + * above strips the redundant `deleteRule('no action')` noise, and + * `@mikro-orm/entity-generator`'s OWN `cleanUpReferentialIntegrityRules` + * (`EntityGenerator.js`) strips a redundant `'cascade'`/`'set null'` the + * same way for the FK-as-PK, fixed-order-pivot and nullable-relation shapes + * — but neither cleanup step re-derives the SAME default this rule computes + * here, so when the physical schema's real `on_delete` differs from what + * MikroORM would infer left implicit, the generated entity silently carries + * the wrong effective rule. Concretely, of the 10: 5 are a nullable relation + * whose column has no `ON DELETE` clause at all in its migration (SQLite ⇒ + * `NO ACTION`) — `trip_members.invited_by`, `oauth_tokens.parent_token_id`, + * `budget_settlements.created_by_user_id`, `budget_items.paid_by_user_id`, + * and `roadtrip_day_tracks.day_id` (nullable AND the table's sole PK — its + * migration DOES say `ON DELETE CASCADE`, so this one goes the other way: + * physical is `cascade`, the nullable default silently gives `set null`); + * `journey_contributors.user_id` is a composite-PK member whose migration + * has no `ON DELETE` (physical `no action`) while its FK-as-PK sibling + * relation, `journey_id`, DOES say `ON DELETE CASCADE` (so its own implicit + * `cascade` default is correct — only `user_id` disagrees); the remaining 4 + * (`reservation_travelers.reservation_id`/`.user_id`, + * `assignment_participants.assignment_id`/`.user_id`) are plain non-nullable + * relations on a table with its own `id INTEGER PRIMARY KEY AUTOINCREMENT` + * — no implicit default applies to them at all (MikroORM has none for that + * shape) — but `@mikro-orm/entity-generator`'s pivot-shaped-table cleanup + * (Case 2: a single autoincrement PK + exactly 2 many-to-one relations and + * no other columns) strips their introspected `'cascade'` on the mistaken + * assumption that MikroORM would restore it as a default, which it does not + * for a non-composite-PK entity; their migrations DO say `ON DELETE CASCADE`. + * + * Every one of the 10 is, per the task's own ruling, an entity that never + * matched its migration's physical FK to begin with — not a migration + * defect — so this rule always fixes the ENTITY side: it pins an explicit + * `deleteRule` equal to the physical value whenever leaving the property + * alone (explicit-if-set, else the same implicit default computed above) + * would produce the wrong effective rule, and otherwise leaves the property + * untouched — matching Rule 7's own "no noise where the implicit default is + * already correct" intent instead of stamping every relation explicitly. + * + * One more wrinkle, found empirically (probed `t.orm.getMetadata()` against + * the real `ALL_ENTITIES`, not assumed): a relation that is BOTH nullable + * AND its entity's sole/composite FK-as-PK member — `RoadtripDayTracks.day`, + * `DawarichConnections.user`, `PlaceRegions.place`, `VacayUserSettings.user`, + * every one of them shaped exactly like `.primary().ref().nullable()` with + * no explicit `deleteRule` — is where BOTH candidate defaults above apply at + * once, and the doc comment's "nullable runs first" precedence is only true + * WITHIN a single `processEntity(meta)` call. Across entities, `Days`'/ + * `Users`'/`Places`' own inverse `.mappedBy(...)` side of the same relation + * also runs `initManyToOneFields`, and depending on `ALL_ENTITIES` + * discovery order that can re-touch the owning prop before ITS OWN + * `processEntity` runs — measured outcome: `RoadtripDayTracks.day` really + * does resolve to `'set null'` at runtime (matching this task's 10), while + * the other three resolve to `'cascade'` (NOT in the 10) despite identical + * source. That order dependency is exactly the kind of fact a future entity + * addition could silently flip, so this rule does not trust "already + * correct" for this one shape — `isAmbiguousNullablePrimaryFk` below always + * pins an explicit `deleteRule` on it, matching physical, even on the three + * that happen to already be correct today. + */ +export function RULE13_pinDeleteRuleDrift( + metadata: EntityMetadata[], + fkDeleteRules: ReadonlyMap>, +): DeleteRuleFixup[] { + const fixups: DeleteRuleFixup[] = []; + for (const meta of metadata) { + const byColumn = fkDeleteRules.get(meta.tableName); + if (!byColumn) continue; + const pks = meta.getPrimaryProps().filter((pk): pk is EntityProperty => pk !== undefined); + const allPksAreFk = pks.length > 0 && pks.every((pk) => pk.kind !== undefined && pk.kind !== ReferenceKind.SCALAR); + for (const prop of meta.relations) { + if (!isOwningToOne(prop) || prop.fieldNames.length !== 1) continue; + const physical = byColumn.get(prop.fieldNames[0]); + if (physical === undefined) continue; // no db FK on this column at all — PARITY-009 proper's job, not this rule's + // The two candidate implicit defaults collide on this one shape — see + // the doc comment's "one more wrinkle" — so never trust "already + // correct" here; always pin it explicitly. + const isAmbiguousNullablePrimaryFk = !!prop.nullable && !!prop.primary && allPksAreFk; + if (!isAmbiguousNullablePrimaryFk) { + // The same precedence MetadataDiscovery applies at runtime within a single + // entity's own processEntity() call: nullable wins over FK-as-PK. + const implicitDefault = prop.nullable ? 'set null' : prop.primary && allPksAreFk ? 'cascade' : 'no action'; + const effectiveIfLeftAsIs = (prop.deleteRule ?? implicitDefault).toLowerCase(); + if (effectiveIfLeftAsIs === physical) continue; // already correct, explicit or by the implicit default alike + } else if ((prop.deleteRule ?? '').toLowerCase() === physical) { + continue; // already pinned explicitly to the right value — nothing to change + } + fixups.push({ className: meta.className, propName: prop.name, from: prop.deleteRule, to: physical }); + prop.deleteRule = physical; + } + } + return fixups; +} + +/** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */ +export interface DefaultFixup { + className: string; + propName: string; + /** Valid TS source for the literal, e.g. `0`, `'calendar'`. */ + literal: string; +} + +/** A raw SQL default that is a plain literal (number or single-quoted string) rather than an expression like `CURRENT_TIMESTAMP`. */ +// Numbers, quoted strings and the booleans RULE1c coerces a NOT NULL boolean default into. +const PLAIN_LITERAL_DEFAULT_RE = /^-?\d+(\.\d+)?$|^'.*'$|^(?:true|false)$/; + +/** + * Rule (metadata level for detection + clearing `defaultRaw`; text level for + * the render itself — see task-2-report.md "the defaults rule"): a scalar + * column with a literal `DEFAULT` (`sort_order INTEGER NOT NULL DEFAULT 0`, + * `year_type TEXT NOT NULL DEFAULT 'calendar'`) is exactly what + * `DayNotes`/`BudgetCategoryOrder`/`VacayUserSettings` render as + * `.default(0)`/`.default('calendar')` — plain JS defaults, never + * `.defaultRaw()`. The stock renderer instead either (a) renders + * `.defaultRaw('0')` whenever introspection only populated `defaultRaw` and + * left `prop.default` unset (observed for columns whose declared type the + * generator leaves `unknown`, e.g. REAL, before Rule 1 retypes them), or (b) + * for a column where BOTH are populated and consistent, renders *neither*: + * traced to `SourceFile.js#getCommonDecoratorOptions`'s `.default()` branch, + * which additionally requires `breakdownOfIType(prop) !== undefined` — true + * only when the property's type is "ambiguous" in some way, which a boring, + * unambiguous `INTEGER NOT NULL DEFAULT 0` column never is. Confirmed by + * generating the real schema and inspecting the metadata mid-hook, not + * assumed. Since nothing in `GenerateOptions` can force that branch to fire, + * this is unavoidably a two-part rule: clear the raw default here (so the + * renderer's OTHER branch, `.defaultRaw()`, never fires either) and record + * a (className, propName, literal) triple for `injectMissingDefaults` to + * append `.default()` in the rendered text whenever the property's + * line ends up with neither call. A genuine SQL expression default + * (`CURRENT_TIMESTAMP`, `datetime('now')`) never matches + * `PLAIN_LITERAL_DEFAULT_RE` and is left completely alone — that path + * already renders correctly today (Rule 2's `DbTimestampType` columns). + */ +export function RULE_normalizeLiteralDefaults(metadata: EntityMetadata[]): DefaultFixup[] { + const fixups: DefaultFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + // Nullable columns are Task 3's PARITY-005 territory (still `it.skip`d, + // see task-0-report.md): Phase 0's own hand-written files are not + // internally consistent about restating a nullable column's DB default + // in metadata at all (`DayNotes.icon` — nullable, `TEXT DEFAULT '📝'` — + // has none; `DayNotes.sort_order` — nullable, `REAL DEFAULT 0` — has + // `.default(0)`). Guessing a rule to match that inconsistency would be + // exactly the kind of drift Task 0 deferred; only a NOT NULL column + // is unambiguous — there, the default is not cosmetic, it is the only + // thing that makes the property optional at all (see this rule's doc + // comment above). + if (prop.nullable) continue; + if (typeof prop.defaultRaw !== 'string') continue; + if (!PLAIN_LITERAL_DEFAULT_RE.test(prop.defaultRaw)) continue; // an expression — leave to Rule 2's defaultRaw path + if (prop.default === undefined || prop.default === null) continue; // introspection gave us no typed value — nothing safe to render + const literal = + typeof prop.default === 'string' ? `'${prop.default.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` : String(prop.default); + delete prop.defaultRaw; + fixups.push({ className: meta.className, propName: prop.name, literal }); + } + } + return fixups; +} + +export interface RuleFixups { + joinColumns: JoinColumnFixup[]; + defaults: DefaultFixup[]; + timestamps: TimestampFixup[]; + jsonColumns: JsonColumnFixup[]; + /** Rule 1's + Rule 1b's + Rule 2's retyped scalars together — all three need the same `.columnType()` strip (see `stripRedundantColumnType`). */ + retypedScalars: RetypedScalarFixup[]; + /** Rule 10's repository-type markers — one per entity class. */ + repositoryMarkers: RepositoryTypeMarkerFixup[]; + /** Rule 11's non-PK FK referenced-column fixups. */ + referencedColumns: ReferencedColumnsFixup[]; + /** Rule 12's garbled-CHECK-expression repairs. */ + checkExpressions: CheckExpressionFixup[]; + /** Rule 13's deleteRule-drift pins — reporting only, no text pass depends on it (the renderer already emits an explicit `.deleteRule(...)` from `prop.deleteRule` alone). */ + deleteRules: DeleteRuleFixup[]; +} + +/** + * Every rule above, composed into the single hook MikroORM calls. Order + * matters (see comments). `implicitUniques`/`fkDeleteRules` default to empty + * so a caller (the fixture tests) that doesn't care about Rule 9/Rule 13 + * need not pass them. + */ +export function applyRules( + metadata: EntityMetadata[], + _platform: Platform, + implicitUniques: ReadonlyMap = new Map(), + fkDeleteRules: ReadonlyMap> = new Map(), +): RuleFixups { + const retypedByRule1 = RULE1_fixUnknownScalarTypes(metadata); + const jsonColumns = RULE1b_markJsonColumns(metadata); + // Unlike Rule 1's/Rule 1b's retypes, a boolean column's own declaration + // matches its raw INTEGER column type cleanly on SQLite (BooleanType's + // getColumnType() returns the same spelling needsExplicitColumnType() + // compares against) — confirmed by generating the real schema: no stray + // `.columnType()` appears, so RULE1c's fixups do NOT belong in + // `retypedScalars` (stripRedundantColumnType's "found no .columnType() to + // strip" throw catches this immediately if that ever stops being true). + RULE1c_markBooleanColumns(metadata); + RULE1d_fixNaNNumericDefaults(metadata); + const timestamps = RULE2_datetimeToDbTimestampType(metadata); + RULE3_integerPkAutoincrement(metadata); + RULE4_hideAllRelations(metadata); + const joinColumns = RULE5_renameOwningRelations(metadata); + RULE6_renameInverseCollections(metadata); + RULE7_dropNoActionRules(metadata); + RULE8_bindRepositories(metadata); + RULE9_addImplicitUniqueConstraints(metadata, implicitUniques); + const defaults = RULE_normalizeLiteralDefaults(metadata); + const repositoryMarkers = RULE10_repositoryTypeMarker(metadata); + // Runs after RULE5 so it reads the FINAL (post-rename) owning-relation + // property names — the same names the text pass below must find in the + // rendered `properties: {...}` block. + const referencedColumns = RULE11_referencedColumns(metadata); + // Independent of every rule above (it only touches `meta.checks`, which + // the renderer dumps verbatim — no text pass depends on it, same as + // RULE7/RULE9); placed last only by convention (highest rule number). + const checkExpressions = RULE12_fixGarbledCheckExpressions(metadata); + // Must run after RULE7 (which already dropped the genuinely redundant + // `deleteRule('no action')` noise) so `prop.deleteRule` reflects what the + // generated source would actually carry before this rule decides whether + // that's still wrong against the live schema. + const deleteRules = RULE13_pinDeleteRuleDrift(metadata, fkDeleteRules); + return { + joinColumns, + defaults, + timestamps, + jsonColumns, + retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns], + repositoryMarkers, + referencedColumns, + checkExpressions, + deleteRules, + }; +} + +/** + * DB-dependent half of Rule 9: for every table name given, finds its implicit + * (`sqlite_autoindex_...`) UNIQUE indexes via the same two PRAGMAs + * `SqliteSchemaHelper.getIndexes()` itself uses (`index_list`, `index_info`), + * and returns their column sets in declared order (`index_info.seqno`). + * + * Kept separate from `RULE9_addImplicitUniqueConstraints` so that rule stays + * a pure function over plain data — this is the only piece of Rule 9 that + * touches a `Connection`, and it is exercised end-to-end (not unit-tested in + * isolation) by `generateEntities()`'s own tests against the real migrated + * schema. + */ +export async function collectImplicitUniqueIndexes( + connection: Connection, + tableNames: readonly string[], +): Promise> { + const result = new Map(); + for (const tableName of tableNames) { + const indexList = (await connection.execute(`pragma index_list(\`${tableName}\`)`, [], 'all')) as { + name: string; + unique: number; + }[]; + const autoUniques = indexList.filter((idx) => idx.unique && idx.name.startsWith('sqlite_autoindex_')); + if (autoUniques.length === 0) continue; + const perTable: string[][] = []; + for (const idx of autoUniques) { + const info = (await connection.execute(`pragma index_info(\`${idx.name}\`)`, [], 'all')) as { + seqno: number; + cid: number; + name: string; + }[]; + perTable.push([...info].sort((a, b) => a.seqno - b.seqno).map((c) => c.name)); + } + result.set(tableName, perTable); + } + return result; +} + +/** + * DB-dependent half of Rule 13: for every table name given, `PRAGMA + * foreign_key_list`'s `on_delete` per `from` (owning) column, lowercased — + * the same physical fact `tests/unit/db/entity-schema-parity.test.ts`'s + * PARITY-009b reads at test time, queried once here so `RULE13_pinDeleteRuleDrift` + * stays a pure function over plain data, same shape as Rule 9's split. + */ +export async function collectForeignKeyDeleteRules( + connection: Connection, + tableNames: readonly string[], +): Promise>> { + const result = new Map>(); + for (const tableName of tableNames) { + const fkRows = (await connection.execute(`pragma foreign_key_list(\`${tableName}\`)`, [], 'all')) as { + from: string; + on_delete: string; + }[]; + if (fkRows.length === 0) continue; + const byColumn = new Map(); + for (const row of fkRows) byColumn.set(row.from, row.on_delete.toLowerCase()); + result.set(tableName, byColumn); + } + return result; +} + +// --------------------------------------------------------------------------- +// onImport — resolves the two identifiers the rules above add to a file's +// entityImports that are NOT sibling `*.entity.ts` classes: `DbTimestampType` +// (rule 2) and any `Repository` (rule 8). Everything else (another entity +// class) falls through to the generator's own default resolution. +// --------------------------------------------------------------------------- +export const onImport: ImportsResolver = (alias) => { + if (alias === 'DbTimestampType') return { path: '../types', name: 'DbTimestampType' }; + if (alias.endsWith('Repository')) { + const entityName = alias.slice(0, -'Repository'.length); + return { path: `../repositories/${entityName}.repository`, name: alias }; + } + return undefined; +}; + +// --------------------------------------------------------------------------- +// Text-level passes — only for what the metadata cannot carry into the +// rendered `defineEntity` call (see the doc comment at the top of this file +// and task-2-report.md for why each of these specifically cannot be a rule +// on the metadata). +// --------------------------------------------------------------------------- + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The property's line inside `properties: {...}` — i.e. the metadata builder + * call (`propName: p.integer()...` or `propName: () => p.manyToOne(...)...`), + * never the class-field declaration above it. The two can share an identical + * `propName:` prefix (an `& Opt`-style field like `sort_order: number & Opt = 0;` + * has no `!`/`?`/parens between the name and the colon, same as the metadata + * line `sort_order: p.integer(),` — anchoring on `propName:.*` alone matches + * whichever comes first in the file, which is always the class field). + * + * The leading padding is `[ \t]*`, not `\s*` — `\s` matches a newline too, so + * on a property preceded by a blank line (common: one blank line separates + * the class body from the `properties: {...}` block, or separates unrelated + * groups within it) `\s*^` could swallow that blank line into the captured + * "line", handing every caller a string with a leading `\n` embedded in it. + * `appendCallBeforeTrailingComma`'s own `^…$` anchors (Fix round 1, I3) are + * not `/m`-flagged and silently fail to match a string shaped like that — + * caught by `TEXT-DEFAULTS-001`, which has exactly that blank line between + * the class field and the metadata line. + */ +function findPropertyBuilderLine(source: string, propName: string): RegExpExecArray | null { + const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}: (?:\\(\\) => )?p\\..*)$`, 'm'); + return lineRe.exec(source); +} + +/** The class-field declaration line for a scalar property — the mirror image of `findPropertyBuilderLine` (same `[ \t]*` reasoning). */ +function findClassFieldLine(source: string, propName: string): RegExpExecArray | null { + const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}[?!]?: (?!\\(\\) => p\\.|p\\.).*)$`, 'm'); + return lineRe.exec(source); +} + +/** + * `DbTimestampType.runtimeType` is `'string'` (it stores the SQLite wire text + * verbatim, never a JS `Date` — see `src/db/types/db-timestamp.type.ts`), but + * the generator has no way to know that: `prop.type` is just the plain string + * `'DbTimestampType'` (Rule 2), unrecognised by `@mikro-orm/core`'s `types` + * registry, and `SourceFile.js#breakdownOfIType`'s fallback for an + * unrecognised declared type re-derives the class field's TS type from the + * COLUMN's own mapped type instead (`DATETIME` -> `DateTimeType` -> `'Date'`) + * — a fallback that, unlike the metadata builder's `p.type(DbTimestampType)` + * output, never consults `prop.runtimeType` at all. Confirmed by generating + * the real schema (`DayNotes.created_at`, `Trips.created_at`/`updated_at`) + * and inspecting the rendered class field, not assumed; there is no metadata + * option that changes this fallback's source. Rewriting `Date` to `string` on + * exactly the class-field line for each Rule-2-retyped property is therefore + * a text pass, not a rule. + */ +export function fixDbTimestampClassFieldTypes(source: string, fixups: readonly TimestampFixup[]): string { + let result = source; + for (const { className, propName } of fixups) { + const match = findClassFieldLine(result, propName); + if (!match) { + throw new Error(`generate-entities: fixDbTimestampClassFieldTypes could not find the class field line for "${className}.${propName}".`); + } + const line = match[1]; + const newLine = line.replace(/\bDate\b/g, 'string'); + if (newLine === line) { + throw new Error( + `generate-entities: fixDbTimestampClassFieldTypes found the class field for "${className}.${propName}" but it had no "Date" to rewrite — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Every property Rule 1 or Rule 2 retyped (`p.unknown()` -> `p.double()`, + * `p.datetime()` -> `p.type(DbTimestampType)`) picks up a spurious explicit + * `.columnType()` from `SourceFile.js#needsExplicitColumnType()`: + * that check compares the introspected raw column spelling (`'REAL'`, + * `'DATETIME'`, verbatim from the migration) against the abstract type's OWN + * declaration string (`DoubleType`/base `Platform#getDoubleDeclarationSQL()` + * literally returns `'double'`; `DbTimestampType` has no declaration of its + * own so the comparison falls back the same way) — they can never textually + * match, so the "is this ambiguous" check is always true for exactly these + * two retyped families, even though `double`/`DbTimestampType` already + * correctly mean `REAL`/`DATETIME` on SQLite. Confirmed by generating the + * real schema (`DayNotes.sort_order`/`.created_at`) and reading + * `getColumnType()`, not assumed. No `GenerateOptions` flag skips this check, + * so the explicit call is stripped here rather than never emitted. + */ +export function stripRedundantColumnType(source: string, fixups: readonly RetypedScalarFixup[]): string { + let result = source; + for (const { className, propName } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: stripRedundantColumnType could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + const newLine = line.replace(/\.columnType\('[^']*'\)/, ''); + if (newLine === line) { + throw new Error( + `generate-entities: stripRedundantColumnType found the property line for "${className}.${propName}" but it had no .columnType() to strip — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Inserts `injected` right before a builder line's trailing comma, tolerating + * a trailing line comment after it (`.ref(), // pins country`) — the plain + * `line.replace(/,\s*$/, …)` this replaced only matched a comma at the very + * end of the line, so a trailing comment made the append silently do nothing + * (Fix round 1, task-2-review.md I3, adversarial fixture A6). Greedy `.*` + * anchors on the LAST comma on the line (the statement terminator), not the + * first one a string-literal default might contain earlier in the chain. + * Returns `undefined` if the line has no trailing comma to anchor on at all. + */ +function appendCallBeforeTrailingComma(line: string, injected: string): string | undefined { + const match = /^(.*)(,)(\s*(?:\/\/.*)?)$/.exec(line); + if (!match) return undefined; + return `${match[1]}${injected}${match[2]}${match[3]}`; +} + +/** + * For every collision-class relation Rule 5 renamed, turns the renderer's own + * (inert, for a relation) `.name('')` override into the + * `.joinColumn('')` that actually pins the FK column — or appends + * `.joinColumn(...)` if the renderer decided no override was needed at all + * (belt and braces; not observed in practice, but the rule must not silently + * do nothing if the renderer's heuristic ever changes). Every branch either + * makes an edit or throws (Fix round 1, task-2-review.md I3) — a fixup was + * recorded specifically because this relation's FK column pin is not + * otherwise correct, so a no-op here would ship a wrong/missing FK silently. + */ +export function injectJoinColumns(source: string, fixups: readonly JoinColumnFixup[]): string { + let result = source; + for (const { className, propName, column } of fixups) { + const quotedColumn = `'${column}'`; + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error( + `generate-entities: injectJoinColumns could not find the property line for "${className}.${propName}" — the recorded joinColumn fixup produced no edit.`, + ); + } + const line = match[1]; + const nameCallRe = new RegExp(`\\.name\\(${escapeRegExp(quotedColumn)}\\)`); + let newLine: string; + if (nameCallRe.test(line)) { + newLine = line.replace(nameCallRe, `.joinColumn(${quotedColumn})`); + } else if (line.includes('.joinColumn(')) { + newLine = line; // already explicit — legitimately nothing to do, not a no-op bug + } else { + const appended = appendCallBeforeTrailingComma(line, `.joinColumn(${quotedColumn})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectJoinColumns found the property line for "${className}.${propName}" but could not append .joinColumn(${quotedColumn}) to it — check the renderer shape.`, + ); + } + newLine = appended; + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * The text half of `RULE_normalizeLiteralDefaults`: appends `.default()` + * to a property's line when it has neither `.default(` nor `.defaultRaw(` — + * the exact gap that rule's doc comment traces. + */ +export function injectMissingDefaults(source: string, fixups: readonly DefaultFixup[]): string { + let result = source; + for (const { className, propName, literal } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectMissingDefaults could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (line.includes('.default(') || line.includes('.defaultRaw(')) continue; // already renders one — legitimately nothing to do + const appended = appendCallBeforeTrailingComma(line, `.default(${literal})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectMissingDefaults found the property line for "${className}.${propName}" but could not append .default(${literal}) to it — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + appended + result.slice(match.index + line.length); + } + return result; +} + +/** + * `p.json()` has no metadata-level channel for a TypeScript generic type + * parameter (see `JSON_COLUMNS`/`JSON_INTERFACES` above) — this inserts the + * recorded type name after each known JSON column's bare `p.json(` call, on + * exactly that property's line (Fix round 1, task-2-review.md M4/I3: the + * previous blanket `source.replace(/\bp\.json\(\)/g, …)` also rewrote a + * `p.json()` mentioned inside a comment — adversarial fixture A2). Already + * being generic (`p.json()`) is a legitimate no-op, not a bug; + * anything else finding no bare call throws. + */ +export function injectJsonTypeParams(source: string, fixups: readonly JsonColumnFixup[]): string { + let result = source; + for (const { className, propName, typeName } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectJsonTypeParams could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (/p\.json<[^>]*>\(/.test(line)) continue; // already explicitly typed + const newLine = line.replace(/\bp\.json\(\)/, `p.json<${typeName}>()`); + if (newLine === line) { + throw new Error( + `generate-entities: injectJsonTypeParams found the property line for "${className}.${propName}" but it had no bare p.json() call to type — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * `JsonType#runtimeType` (`@mikro-orm/core/types/JsonType.js`) is a hardcoded + * getter that always returns `'any'`, never consulting the property at all — + * so even with `prop.runtimeType` correctly set to the interface name (Rule + * 1b), `SourceFile.js#breakdownOfIType` still sees `prop.runtimeType + * ('AddonConfig') !== rawType ('any')` and wraps the class field in + * `IType` (confirmed by generating the real schema and + * reading the rendered field, not assumed — there is no metadata option that + * changes this). `any` in generated output is not acceptable (Fix round 1, + * task-2-review.md C3), so this text pass simplifies the class field's + * `IType` down to the plain `TypeName`, the same shape a + * hand-written entity uses. A field already rendering the plain type name is + * a legitimate no-op; anything else finding neither shape throws. + */ +export function fixJsonClassFieldTypes(source: string, fixups: readonly JsonColumnFixup[]): string { + let result = source; + for (const { className, propName, typeName } of fixups) { + const match = findClassFieldLine(result, propName); + if (!match) { + throw new Error(`generate-entities: fixJsonClassFieldTypes could not find the class field line for "${className}.${propName}".`); + } + const line = match[1]; + const iTypeRe = new RegExp(`IType<${escapeRegExp(typeName)}(?:\\s*,\\s*any)?>`); + const newLine = line.replace(iTypeRe, typeName); + if (newLine === line) { + if (line.includes(typeName) && !line.includes('any')) continue; // already the plain type — nothing to fix + throw new Error( + `generate-entities: fixJsonClassFieldTypes found the class field for "${className}.${propName}" but no IType<${typeName}, any> to simplify — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Inserts the `JSON_INTERFACES` declaration for every distinct type name this + * file's JSON columns reference, right before `export class ` — the + * same position a hand-written file like the current `Addons.entity.ts` uses. + * Throws if `JSON_COLUMNS` ever references a type name with no matching + * `JSON_INTERFACES` entry (a maintenance gap, never a schema fact to guess + * at) or if the file has no `export class` line to anchor on. + * + * Also strips the bogus self-import the renderer adds for the type name: + * `breakdownOfIType`'s import-collection step (`SourceFile.js`) treats any + * non-primitive `prop.runtimeType` as if it names a sibling entity class and + * adds it to `entityImports` — `onImport` has no "this identifier needs no + * import, it is declared right here" signal, so the generator's default + * resolution guesses `import { AddonConfig } from './AddonConfig.entity';`, + * a file that does not exist. Confirmed by generating the real schema, not + * assumed. + */ +export function injectJsonInterfaces(source: string, fixups: readonly JsonColumnFixup[]): string { + if (fixups.length === 0) return source; + const typeNames = [...new Set(fixups.map((f) => f.typeName))]; + const blocks: string[] = []; + let result = source; + for (const typeName of typeNames) { + const decl = JSON_INTERFACES[typeName]; + if (!decl) { + throw new Error(`generate-entities: JSON_COLUMNS references type "${typeName}" but JSON_INTERFACES has no declaration for it.`); + } + blocks.push(decl); + const escaped = escapeRegExp(typeName); + const bogusImportRe = new RegExp(`^import \\{ ${escaped} \\} from '\\./${escaped}\\.entity';\\n`, 'm'); + result = result.replace(bogusImportRe, ''); + // The generator resolves an unknown type name to a sibling entity import; + // nothing else may still import the interface from a file that never exists. + if (new RegExp(`from '\\./${escaped}\\.entity'`).test(result)) { + throw new Error(`generate-entities: injectJsonInterfaces left an import of "${typeName}" from a nonexistent ./${typeName}.entity file.`); + } + } + const classRe = /^export class \w+ /m; + const match = classRe.exec(result); + if (!match) { + throw new Error('generate-entities: injectJsonInterfaces could not find "export class" to insert the interface declaration before.'); + } + return result.slice(0, match.index) + blocks.join('\n') + '\n' + result.slice(match.index); +} + +/** + * Removes `identifier` from the `@mikro-orm/core` named import line if + * nothing after that line still references it as a bare word — shared by + * `stripHiddenTypeAnnotation` (`Hidden`) and `fixJsonClassFieldTypes`'s + * caller (`IType`), both of which can make an import genuinely unused. + */ +function removeUnusedCoreImportIfUnused(source: string, identifier: string): string { + const importLineRe = /^import \{[^}]*\} from '@mikro-orm\/core';$/m; + const coreImportLine = importLineRe.exec(source)?.[0]; + if (!coreImportLine) return source; + const bodyAfterImports = source.slice(source.indexOf(coreImportLine) + coreImportLine.length); + if (new RegExp(`\\b${escapeRegExp(identifier)}\\b`).test(bodyAfterImports)) return source; // still used elsewhere + const escaped = escapeRegExp(identifier); + const cleaned = coreImportLine.replace(new RegExp(`type ${escaped}, `), '').replace(new RegExp(`, type ${escaped}\\b`), ''); + return source.replace(coreImportLine, cleaned); +} + +/** + * Strips the class-field `& Hidden` TypeScript annotation the renderer adds, + * unconditionally, to every field whose property is `.hidden()` + * (`SourceFile.js#getPropertyDefinition`: `if (prop.hidden) { hiddenType += ' & Hidden'; }` + * — there is no metadata option that suppresses it; it does not read a + * config flag, it reads `prop.hidden` directly). Phase 0's hand-written files + * never use this marker on any of their many hidden relations/collections — + * the runtime source of truth is `defineEntity`'s own `.hidden()` call kept + * on the property builder (rule 4), which this text pass never touches; the + * class field is compile-time-only TypeScript, so dropping the redundant + * annotation changes nothing observable. + * + * A `Collection` field only gets an explicit `: Collection` type + * annotation in the first place *because* of `& Hidden` (the same ternary); + * once the marker is gone the whole annotation is dropped too, reverting to + * plain `field = new Collection(this);` — Phase 0's actual style. + * + * Scoped to the CLASS BODY only — everything from the start of the file up + * to (not including) `export const Schema = defineEntity(` — never the + * `properties: {...}` block that follows it. Fix round 1 (task-2-review.md + * M3/I3): the previous whole-file `/ & Hidden/g` also matched a string + * literal default containing that exact text (adversarial fixture A1, + * `note: p.text().default('a & Hidden b')`), corrupting the default's value; + * that text only ever appears in the schema block, which this scoping never + * touches. The `Collection` pattern is also anchored to a single line + * (`[^=\n]*`, not `[^=]*`) so it cannot span two adjacent field declarations + * where the first has no initialiser (fixture A3) — unreachable against + * today's renderer (every hidden `Collection` field DOES get an initialiser, + * see rule 4's doc comment), but no longer exploitable either way. + */ +export function stripHiddenTypeAnnotation(source: string): string { + const boundaryRe = /^export const \w+Schema = defineEntity\(/m; + const boundaryMatch = boundaryRe.exec(source); + const splitIndex = boundaryMatch ? boundaryMatch.index : source.length; + const classBody = source.slice(0, splitIndex); + const rest = source.slice(splitIndex); + + const strippedClassBody = classBody + // Collection & Hidden = new Collection(this); -> = new Collection(this); + .replace(/: Collection<[^=\n]*> & Hidden = /g, ' = ') + // (Ref | null) & Hidden -> Ref | null (parenthesised nullable ref) + .replace(/\(([^()\n]*)\) & Hidden/g, '$1') + // trip!: Ref & Hidden; -> trip!: Ref; (everything else, one class-field line at a time) + .replace(/^(\s*\S[^\n]*?) & Hidden/gm, '$1'); + + if (/&\s*Hidden\b/.test(strippedClassBody)) { + throw new Error('generate-entities: stripHiddenTypeAnnotation left an "& Hidden" marker unstripped in the class body — check the renderer shape.'); + } + + return removeUnusedCoreImportIfUnused(strippedClassBody + rest, 'Hidden'); +} + +/** + * Case-sensitive alpha sort key for a `@mikro-orm/core` named-import entry — + * matches `@mikro-orm/entity-generator`'s own `generateImports()` (sorts the + * raw identifiers, THEN prepends `type ` to whichever ones need it), read + * directly out of `SourceFile.js`. Ignoring any existing `type ` prefix for + * ordering purposes is what keeps this idempotent against the generator's + * own output — re-running `--write` after this pass must never reorder what + * the generator already got right. + */ +function coreImportSortKey(entry: string): string { + return entry.replace(/^type /, ''); +} + +/** + * Adds `identifier` (a VALUE import — never `type`-prefixed; see callers) to + * the file's `@mikro-orm/core` named import line, re-sorting the whole list + * with `coreImportSortKey` so the result is byte-identical to what + * `@mikro-orm/entity-generator` would have rendered had it known about this + * identifier itself. A no-op if the identifier is already present (every + * generated file has exactly one `@mikro-orm/core` import line — `defineEntity` + * and `p` are unconditional — so "no import line at all" is a renderer-shape + * change, not a legitimate input, and throws rather than silently doing + * nothing. + */ +function addNamedCoreImport(source: string, identifier: string): string { + const importLineRe = /^import \{ ([^}]*) \} from '@mikro-orm\/core';$/m; + const match = importLineRe.exec(source); + if (!match) { + throw new Error(`generate-entities: addNamedCoreImport could not find the "@mikro-orm/core" import line to add "${identifier}" to.`); + } + const entries = match[1].split(',').map((e) => e.trim()); + if (entries.some((e) => coreImportSortKey(e) === identifier)) return source; // already present + entries.push(identifier); + entries.sort((a, b) => { + const ka = coreImportSortKey(a); + const kb = coreImportSortKey(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); + const newLine = `import { ${entries.join(', ')} } from '@mikro-orm/core';`; + return source.slice(0, match.index) + newLine + source.slice(match.index + match[0].length); +} + +/** + * The text half of Rule 10: inserts `[EntityRepositoryType]?: XRepository;` + * as the class's FIRST member — before an existing `[PrimaryKeyProp]`/ + * `[EagerProps]` marker, if either is present (`generateClassDefinition()` + * only ever emits those two, plus `[Config]` for a custom base entity this + * codebase does not use) — and adds `EntityRepositoryType` to the + * `@mikro-orm/core` named import line. + * + * `EntityRepositoryType` is a real runtime `unique symbol` const, not an + * erased type (`POSSIBLE_TYPE_IMPORTS` in the installed entity-generator's + * `CoreImportsHelper.js` does not list it), so this is a VALUE import, never + * `import type` — the same way the generator's own decorator-mode renderer + * imports it, and the same way every existing `[PrimaryKeyProp]` marker in + * this tree already imports `PrimaryKeyProp` as a value (see e.g. + * `AppSettings.entity.ts`). + */ +export function injectRepositoryTypeMarker(source: string, fixups: readonly RepositoryTypeMarkerFixup[]): string { + let result = source; + for (const { className, repositoryClassName } of fixups) { + const classOpenRe = new RegExp(`^(export class ${escapeRegExp(className)} \\{)$`, 'm'); + const match = classOpenRe.exec(result); + if (!match) { + throw new Error( + `generate-entities: injectRepositoryTypeMarker could not find "export class ${className} {" to insert the marker after.`, + ); + } + const insertAt = match.index + match[0].length; + const marker = `\n [EntityRepositoryType]?: ${repositoryClassName};`; + result = result.slice(0, insertAt) + marker + result.slice(insertAt); + result = addNamedCoreImport(result, 'EntityRepositoryType'); + } + return result; +} + +/** + * The text half of Rule 11: appends `.referencedColumnNames(, …)` to a + * FK relation's property line whenever the renderer left it out despite the + * metadata already carrying the real (non-PK) referenced column — see + * `RULE11_referencedColumns`'s doc comment for why the renderer never emits + * this itself for a single-column FK. `p.manyToOne(...)`'s + * `.referencedColumnNames(...)` is a rest-args method (confirmed against the + * installed `node_modules/@mikro-orm/core/entity/defineEntity.d.ts`), so this + * renders bare comma-separated quoted column names, never a `[...]` array + * literal. A property line that already has an explicit + * `.referencedColumnNames(` call is a legitimate no-op (never observed today + * — the renderer's own emission path never fires for this shape, see the + * rule's doc comment — but a future generator upgrade could change that); + * anything else finding no trailing comma to anchor on throws. + */ +export function injectReferencedColumns(source: string, fixups: readonly ReferencedColumnsFixup[]): string { + let result = source; + for (const { className, propName, referencedColumnNames } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectReferencedColumns could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (line.includes('.referencedColumnNames(')) continue; // already explicit — nothing to do + const quoted = referencedColumnNames.map((column) => `'${column}'`).join(', '); + const appended = appendCallBeforeTrailingComma(line, `.referencedColumnNames(${quoted})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectReferencedColumns found the property line for "${className}.${propName}" but could not append ` + + `.referencedColumnNames(${quoted}) to it — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + appended + result.slice(match.index + line.length); + } + return result; +} + +/** Every text pass, applied in order. */ +export function applyTextPasses(source: string, fixups: RuleFixups): string { + let result = injectJoinColumns(source, fixups.joinColumns); + result = injectMissingDefaults(result, fixups.defaults); + result = fixDbTimestampClassFieldTypes(result, fixups.timestamps); + result = stripRedundantColumnType(result, fixups.retypedScalars); + result = injectJsonTypeParams(result, fixups.jsonColumns); + result = fixJsonClassFieldTypes(result, fixups.jsonColumns); + result = injectJsonInterfaces(result, fixups.jsonColumns); + result = removeUnusedCoreImportIfUnused(result, 'IType'); + result = stripHiddenTypeAnnotation(result); + result = injectRepositoryTypeMarker(result, fixups.repositoryMarkers); + result = injectReferencedColumns(result, fixups.referencedColumns); + return result; +} + +// --------------------------------------------------------------------------- +// Temp database — a throwaway file, migrated the same way buildApp() does +// (same Migrator extension, same migrations path/pathTs), never data/travel.db. +// +// This deliberately does NOT import src/db/orm.ts / src/db/database.ts / +// src/mikro-orm.config.ts: database.ts opens resolveDbPath() (real +// data/travel.db, absent an env override) as a side effect of being imported +// at all, and orm.ts/mikro-orm.config.ts both pull that in transitively. A +// self-contained MikroORM.init() against our own temp path has no such +// side effect and needs nothing from those modules. +// --------------------------------------------------------------------------- + +function tempDbPath(): string { + return path.join(os.tmpdir(), `trek-gen-entities-${crypto.randomUUID()}.db`); +} + +async function migrateTempDb(dbPath: string): Promise { + const orm = await SqliteMikroORM.init({ + entities: [], + discovery: { warnWhenNoEntities: false }, + dbName: dbPath, + extensions: [Migrator], + // Anchored to SERVER_ROOT (__dirname-based), not process.cwd() — Fix + // round 1, task-2-review.md I5: run from the repo root instead of + // server/, the old relative paths silently resolved to nothing and + // `generator.generate()` produced zero files with exit 0. `assertFilesGenerated` + // below is the second half of the fix: even if some future refactor + // reintroduces a cwd-relative path, a zero-file run throws instead of + // looking like a quiet success. + // silent: the 260 `[migrator] Processing/Applied` lines are the migrator's own + // progress log, not this script's output; check:entities and the generator test + // report in their own words and a failure still throws. + migrations: { path: path.join(SERVER_ROOT, 'dist/db/migrations'), pathTs: path.join(SERVER_ROOT, 'src/db/migrations'), snapshot: false, silent: true }, + }); + try { + const migrator = orm.config.getExtension('@mikro-orm/migrator'); + await migrator.up(); + } finally { + await orm.close(true); + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +export interface GenerateResult { + /** `X.entity.ts` -> final, post-text-pass file content. */ + files: Map; +} + +/** + * Throws if `files` is empty — a zero-entity run is never a legitimate + * result of this wrapper (the migrated schema always has tables), only a + * silent path-resolution failure (Fix round 1, task-2-review.md I5: + * `node --import tsx server/scripts/generate-entities.ts --out …` run from + * the repo root instead of `server/` printed `dumped 0 files`, exit 0, + * before the migrations path was anchored to `SERVER_ROOT`). Exported so it + * can be pinned by a fixture test independent of a real schema/cwd. + */ +export function assertFilesGenerated(files: ReadonlyMap): void { + if (files.size === 0) { + throw new Error( + 'generate-entities: generated zero entity files — check the migrations path/cwd (should be anchored to SERVER_ROOT) and that the schema actually has tables.', + ); + } +} + +/** + * Generates every entity from the migrated schema into memory (nothing is + * written to disk by this function alone — see `writeEntities`/`main` below + * for the on-disk half `--write` uses). + */ +export async function generateEntities(): Promise { + const dbPath = tempDbPath(); + try { + await migrateTempDb(dbPath); + + const orm = await SqliteMikroORM.init({ + entities: [], + discovery: { warnWhenNoEntities: false }, + dbName: dbPath, + namingStrategy: SnakeProps, + extensions: [EntityGenerator], + }); + try { + const generator = orm.config.getExtension('@mikro-orm/entity-generator'); + let fixups: RuleFixups = { + joinColumns: [], + defaults: [], + timestamps: [], + jsonColumns: [], + retypedScalars: [], + repositoryMarkers: [], + referencedColumns: [], + checkExpressions: [], + deleteRules: [], + }; + const rawFiles = await generator.generate({ + entityDefinition: 'defineEntity', + scalarPropertiesForRelations: 'always', + identifiedReferences: true, + bidirectionalRelations: true, + forceUndefined: false, + undefinedDefaults: true, + skipTables: ['mikro_orm_migrations'], + fileName: (className: string) => `${className}.entity`, + onImport, + onProcessedMetadata: async (metadata, platform) => { + // Same connection the generator itself just introspected the schema + // through (`orm`, in scope from just above) — Rule 9/Rule 13 need + // no second connection or a second migrated temp DB. + const tableNames = metadata.map((meta) => meta.tableName); + const implicitUniques = await collectImplicitUniqueIndexes(orm.em.getConnection(), tableNames); + const fkDeleteRules = await collectForeignKeyDeleteRules(orm.em.getConnection(), tableNames); + fixups = applyRules(metadata, platform, implicitUniques, fkDeleteRules); + }, + }); + + const files = new Map(); + for (const raw of rawFiles) { + const classMatch = /\nexport class (\w+) /.exec(raw) ?? /^export class (\w+) /.exec(raw); + if (!classMatch) continue; // a routine/enum file, no class of its own — nothing to text-pass + const className = classMatch[1]; + const relevantFixups: RuleFixups = { + joinColumns: fixups.joinColumns.filter((f) => f.className === className), + defaults: fixups.defaults.filter((f) => f.className === className), + timestamps: fixups.timestamps.filter((f) => f.className === className), + jsonColumns: fixups.jsonColumns.filter((f) => f.className === className), + retypedScalars: fixups.retypedScalars.filter((f) => f.className === className), + repositoryMarkers: fixups.repositoryMarkers.filter((f) => f.className === className), + referencedColumns: fixups.referencedColumns.filter((f) => f.className === className), + checkExpressions: fixups.checkExpressions.filter((f) => f.className === className), + deleteRules: fixups.deleteRules.filter((f) => f.className === className), + }; + files.set(`${className}.entity.ts`, applyTextPasses(raw, relevantFixups)); + } + assertFilesGenerated(files); + return { files }; + } finally { + await orm.close(true); + } + } finally { + fs.rmSync(dbPath, { force: true }); + } +} + +/** Writes `files` into `dir` (creating it if needed). Used for the scratch dump and by `--write`. */ +export function dumpEntities(dir: string, files: ReadonlyMap): void { + fs.mkdirSync(dir, { recursive: true }); + for (const [fileName, content] of files) { + fs.writeFileSync(path.join(dir, fileName), content); + } +} + +/** + * For every generated entity, writes `src/db/repositories/.repository.ts` + * if (and only if) it does not already exist — Rule 8's file-system half. + * Returns the list of newly-created files. + */ +export function writeRepositoriesIfMissing(repositoriesDir: string, files: ReadonlyMap): string[] { + const created: string[] = []; + fs.mkdirSync(repositoriesDir, { recursive: true }); + for (const fileName of files.keys()) { + const className = fileName.replace(/\.entity\.ts$/, ''); + const repoFile = path.join(repositoriesDir, `${className}.repository.ts`); + if (fs.existsSync(repoFile)) continue; + // `TrekRepository`, never the raw `EntityRepository` (program RULING, + // Plan 3b Task 1 — every repository validates the request context and + // defaults reads to `disableIdentityMap: true`; Task 7 review H2): a + // scaffold on the bare base class silently opts the next table out of + // both guarantees, with nothing to catch it. See + // `_shared/trek-repository.ts` and the `instanceof TrekRepository` + // ratchet in `tests/unit/db/entities-index.test.ts`. + const content = + `import type { ${className} } from '../entities/${className}.entity';\n` + + `import { TrekRepository } from './_shared/trek-repository';\n\n` + + `export class ${className}Repository extends TrekRepository<${className}> {}\n`; + fs.writeFileSync(repoFile, content); + created.push(repoFile); + } + return created; +} + +/** Builds `src/db/entities/index.ts`'s source for a given, already-sorted class-name list. */ +export function buildEntitiesIndexSource(classNames: readonly string[]): string { + const imports = classNames.map((name) => `import { ${name}Schema } from './${name}.entity';`).join('\n'); + const exportList = classNames.map((name) => ` ${name}Schema,`).join('\n'); + return ( + `import type { EntitySchema } from '@mikro-orm/core';\n${imports}\n\n` + + `export const ALL_ENTITIES: readonly EntitySchema[] = [\n${exportList}\n];\n` + ); +} + +/** Regenerates `src/db/entities/index.ts` from the directory, sorted, exporting `ALL_ENTITIES`. */ +export function regenerateEntitiesIndex(entitiesDir: string): string { + const classNames = fs + .readdirSync(entitiesDir) + .filter((f) => f.endsWith('.entity.ts')) + .sort() + .map((f) => f.replace(/\.entity\.ts$/, '')); + return buildEntitiesIndexSource(classNames); +} + +/** What `--check` found: every generated file (entity + `index.ts`) that differs from — or is missing from — disk, and every repository `--write` would create. Read-only; never touches disk. */ +export interface CheckReport { + /** `X.entity.ts` (or `'index.ts'`) for every generated file whose on-disk content differs, byte-for-byte, from what the generator produces now. */ + differingFiles: string[]; + /** `src/db/repositories/.repository.ts` for every entity with no repository file yet — `--write` would create these. */ + missingRepositories: string[]; +} + +/** + * The read-only half of `--check` (I1 in `task-4-review-gates.md`): compares + * `files` (already generated in memory by `generateEntities()`) plus the + * `index.ts` they imply against `entitiesDir`, and reports which + * `repositoriesDir/.repository.ts` files `writeRepositoriesIfMissing` + * would create. Never reads `regenerateEntitiesIndex`'s directory-listing + * variant — the *expected* index is built straight from `files`' own key + * set, not from whatever happens to already be on disk, so a stray or + * missing entity file cannot mask itself out of the index comparison. + */ +export function checkEntities(entitiesDir: string, repositoriesDir: string, files: ReadonlyMap): CheckReport { + const differingFiles: string[] = []; + for (const [fileName, content] of files) { + const onDiskPath = path.join(entitiesDir, fileName); + const onDisk = fs.existsSync(onDiskPath) ? fs.readFileSync(onDiskPath, 'utf8') : undefined; + if (onDisk !== content) differingFiles.push(fileName); + } + + const classNames = [...files.keys()].map((f) => f.replace(/\.entity\.ts$/, '')).sort(); + const expectedIndex = buildEntitiesIndexSource(classNames); + const indexPath = path.join(entitiesDir, 'index.ts'); + const onDiskIndex = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : undefined; + if (onDiskIndex !== expectedIndex) differingFiles.push('index.ts'); + + // The comparison must also run the other way: a hand-added `*.entity.ts` + // that the generator does not produce would be folded into index.ts by the + // next --write, so it is drift too. + const generated = new Set(files.keys()); + const onDiskEntityFiles = fs.existsSync(entitiesDir) + ? fs.readdirSync(entitiesDir).filter((f) => f.endsWith('.entity.ts')).sort() + : []; + for (const fileName of onDiskEntityFiles) { + if (!generated.has(fileName)) differingFiles.push(`${fileName} (not produced by the generator)`); + } + + const missingRepositories: string[] = []; + for (const fileName of files.keys()) { + const className = fileName.replace(/\.entity\.ts$/, ''); + const repoFile = path.join(repositoriesDir, `${className}.repository.ts`); + if (!fs.existsSync(repoFile)) missingRepositories.push(repoFile); + } + + return { differingFiles, missingRepositories }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const args = process.argv.slice(2); + const write = args.includes('--write'); + const check = args.includes('--check'); + const outIndex = args.indexOf('--out'); + const outDir = outIndex >= 0 ? args[outIndex + 1] : undefined; + + const { files } = await generateEntities(); + + if (check) { + const { differingFiles, missingRepositories } = checkEntities(ENTITIES_DIR, REPOSITORIES_DIR, files); + for (const fileName of differingFiles) { + console.error(`[generate-entities] DRIFT: src/db/entities/${fileName} does not match the generator.`); + } + for (const repoFile of missingRepositories) { + console.error(`[generate-entities] MISSING: ${path.relative(SERVER_ROOT, repoFile)} would be created by --write.`); + } + if (differingFiles.length > 0 || missingRepositories.length > 0) { + console.error('[generate-entities] --check failed. Run: node --import tsx scripts/generate-entities.ts --write'); + process.exit(1); + } + console.log('[generate-entities] --check: entity files match the generator'); + return; + } + + if (write) { + dumpEntities(ENTITIES_DIR, files); + const created = writeRepositoriesIfMissing(REPOSITORIES_DIR, files); + fs.writeFileSync(path.join(ENTITIES_DIR, 'index.ts'), regenerateEntitiesIndex(ENTITIES_DIR)); + console.log(`[generate-entities] wrote ${files.size} entities, ${created.length} new repositories, index.ts`); + return; + } + + const target = outDir ?? path.join(os.tmpdir(), `trek-generated-entities-${crypto.randomUUID()}`); + dumpEntities(target, files); + console.log(`[generate-entities] dumped ${files.size} files to ${target}`); +} + +if (require.main === module) { + main().catch((err: unknown) => { + console.error('[generate-entities] failed:', err); + process.exit(1); + }); +} diff --git a/server/src/app-config/nest-log-levels.ts b/server/src/app-config/nest-log-levels.ts new file mode 100644 index 000000000..84e86111b --- /dev/null +++ b/server/src/app-config/nest-log-levels.ts @@ -0,0 +1,25 @@ +import type { LogLevel } from '@nestjs/common'; + +/** + * LOG_LEVEL → the Nest logger levels `NestFactory.create` should keep. + * + * Nest's built-in logger ignores LOG_LEVEL and prints everything, so every + * boot wrote its full route map (`RouterExplorer`/`RoutesResolver`/ + * `InstanceLoader`, ~700 lines) no matter what the operator asked for — and + * every e2e boot in the test suite did the same, which is most of what a + * green CI log was made of. The file logger (`nest/audit/audit-log.logger.ts`) + * already ranks error < warn < info < debug; this is the same ladder expressed + * in Nest's vocabulary. Unset or unknown falls back to `info`, as index.ts does. + */ +export function nestLogLevels(logLevel: string | undefined): LogLevel[] { + switch ((logLevel ?? 'info').toLowerCase()) { + case 'error': + return ['fatal', 'error']; + case 'warn': + return ['fatal', 'error', 'warn']; + case 'debug': + return ['fatal', 'error', 'warn', 'log', 'debug', 'verbose']; + default: + return ['fatal', 'error', 'warn', 'log']; + } +} diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index ddca87bcd..da02cb5cc 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -1,25 +1,31 @@ -import nodeHttp from 'node:http'; -import express from 'express'; -import type { Request, Response, NextFunction, RequestHandler } from 'express'; -import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import type { INestApplication } from '@nestjs/common'; -import type { ConfigType } from '@nestjs/config'; -import { AppModule } from './nest/app.module'; -import { httpConfig } from './nest/app-config'; +import { attachOrm, runSchemaBootstrap } from './db/orm'; import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware'; -import { SettingsService } from './nest/settings/settings.service'; -import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes'; +import { readEnv } from './app-config'; +import { nestLogLevels } from './app-config/nest-log-levels'; +import { httpConfig } from './nest/app-config'; +import { AppModule } from './nest/app.module'; import { apiDocsEnabled } from './nest/common/api-docs.kill-switch'; +import { validateBodyContracts } from './nest/common/validate-body-contracts'; +import { validateManagedRoutes } from './nest/common/validate-managed-routes'; +import { validateRouteGuards } from './nest/common/validate-route-guards'; import { setupApiDocs } from './nest/platform/api-docs'; import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware'; -import { validateBodyContracts } from './nest/common/validate-body-contracts'; -import { validateRouteGuards } from './nest/common/validate-route-guards'; -import { validateManagedRoutes } from './nest/common/validate-managed-routes'; +import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes'; import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter'; +import { SettingsService } from './nest/settings/settings.service'; import { StorageService } from './nest/storage/storage.service'; +import { MikroORM } from '@mikro-orm/core'; +import { withRequestContext } from './nest/database/request-context'; +import type { INestApplication } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; import { MAX_COLLECTION_FILE_BYTES } from '@trek/shared'; +import express from 'express'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import nodeHttp from 'node:http'; + /** * Builds the unified TREK NestJS application that serves the ENTIRE surface — the * former Express app is gone. One builder is shared by the production bootstrap @@ -70,7 +76,19 @@ export async function buildApp(): Promise { // rawBody keeps the unparsed request bytes on req.rawBody so a plugin webhook // route can verify a provider's HMAC signature over the exact payload (the // parsed JSON alone can't be re-serialised byte-for-byte). - const app = await NestFactory.create(AppModule, new ExpressAdapter(), { rawBody: true }); + // `logger` is the only place Nest's own logger learns about LOG_LEVEL; without + // it every boot prints its full route map whatever the operator asked for. + const app = await NestFactory.create(AppModule, new ExpressAdapter(), { + rawBody: true, + logger: nestLogLevels(readEnv().app.logLevel), + }); + // Schema first, before ANY consumer reads it. `database.ts` only opens the + // connection now; migrating and seeding is MikroORM's job and it is async, so + // this is the earliest point it can happen. It has to stay above the + // SettingsService resolution below, which is the boot's first DB read. + const orm = app.get(MikroORM); + attachOrm(orm); + await runSchemaBootstrap(orm); const instance = app.getHttpAdapter().getInstance(); // The http server is created HERE, not by the caller after buildApp returns, // and that ordering is the whole point: Nest binds gateways during app.init(), @@ -80,7 +98,7 @@ export async function buildApp(): Promise { // boot succeeds, the gateway logs as registered, every test passes, and no // browser can connect. Callers take the server from getHttpServer() below. boundHttpServer = nodeHttp.createServer(instance); - app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer)); + app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer, orm)); // ConfigModule.forRoot's load factories already ran inside NestFactory.create, // so the boot-stable snapshot is resolvable here, BEFORE app.init() — this is // the one bridge that lets the pre-init Express layer consume the validated @@ -91,19 +109,19 @@ export async function buildApp(): Promise { // engines go through the same door — the second one answers the avoidance questions // the first cannot, and is blocked just as silently when the policy leaves it out. const settings = app.get(SettingsService, { strict: false }); - const defaults = settings?.getAdminUserDefaults(); + // Boot runs outside any HTTP request, so there is no per-request EntityManager + // yet and `allowGlobalContext` is off in production: every repository-backed + // read at boot goes through D6's explicit context (see request-context.ts). + const defaults = await withRequestContext(orm, () => settings?.getAdminUserDefaults()); const asUrl = (value: unknown) => (typeof value === 'string' ? value : null); applyGlobalMiddleware(instance, { http, - extraConnectSrc: routingCspOrigins([ - asUrl(defaults?.routing_base_url), - asUrl(defaults?.valhalla_base_url), - ]), + extraConnectSrc: routingCspOrigins([asUrl(defaults?.routing_base_url), asUrl(defaults?.valhalla_base_url)]), }); // Same pre-init consumption bridge as httpConfig above: the StorageService // instance is resolvable before init, and the handlers only *register* here — // per-request resolution runs after app.init() completed the registry load. - applyPlatformUploads(instance, app.get(StorageService)); + applyPlatformUploads(instance, app.get(StorageService), orm); // The SDK discovery router (+ its addon gate). Container-built so its deps // are injected (same pre-init consumption bridge as httpConfig above), but // applied here as a PATHLESS app.use: the SDK router matches absolute @@ -159,8 +177,7 @@ export async function buildApp(): Promise { const json = express.json({ limit: '100kb', verify: rawBodyKeeper }); const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper }); const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/'; - const isBookWrite = (req: Request) => - req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path); + const isBookWrite = (req: Request) => req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path); const isListFile = (req: Request) => req.method === 'POST' && /^\/api\/addons\/collections\/(import|gpx\/read|\d+\/import)$/.test(req.path); @@ -173,6 +190,17 @@ export async function buildApp(): Promise { return isMcp(req) ? next() : urlencoded(req, res, next); }); if (apiDocsEnabled()) setupApiDocs(app); + // The per-request EntityManager fork every Nest route runs inside (D6). This + // used to be @mikro-orm/nestjs's own middleware, which Nest 11 mounts as an + // app.all('/{*all}') route: Express decodes the wildcard param before the + // handler runs, so a malformed %-escape anywhere (GET /uploads/avatars/%ZZ) + // died as a 400 "Failed to decode param" instead of falling through to the + // 404 envelope (UPLOADS-P16). A pathless app.use has no param to decode. + // Registered last before init, where the module middleware used to sit. + // AppModule turns the module's own registration off (registerRequestContext). + instance.use(function mikroOrmRequestContext(_req: Request, _res: Response, next: NextFunction) { + withRequestContext(orm, next); + }); await app.init(); // Fail closed on unvalidated mutation bodies: every POST/PUT/PATCH @Body() // must carry a createZodDto class (validated by the global ZodValidationPipe) diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index b2923add2..a4659cd54 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -3,7 +3,8 @@ import Database from 'better-sqlite3'; /** * Opens a SQLite connection. Every handle the server opens comes from here (the * main database, each plugin's own database, a backup being checked before a - * restore), so what has to hold for all of them is set in one place. + * restore), so what has to hold for all of them is set in one place. A Buffer + * opens a serialized database in memory (the test schema snapshot). * * Temporary storage stays in memory. Statement journals, sort runs and transient * indices move to a temp file once they outgrow a small in-memory buffer, and @@ -20,7 +21,7 @@ import Database from 'better-sqlite3'; * being a tmpfs. VACUUM INTO, the only VACUUM TREK runs, writes straight into * its target file and builds no temporary database. */ -export function openDatabase(filename: string, options?: Database.Options): Database.Database { +export function openDatabase(filename: string | Buffer, options?: Database.Options): Database.Database { const db = new Database(filename, options); db.pragma('temp_store = MEMORY'); return db; diff --git a/server/src/db/database.ts b/server/src/db/database.ts index dbab97d67..ef7404f7d 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -1,46 +1,41 @@ -import type Database from 'better-sqlite3'; -import path from 'path'; -import fs from 'fs'; import { readEnv } from '../app-config'; import { openDatabase } from './connection'; +import { resolveDbPath } from './db-path'; import { applyDurabilityPragmas } from './durability'; -import { createTables } from './schema'; -import { runMigrations } from './migrations'; -import { runSeeds } from './seeds'; -import { Place, Tag } from '../types'; +import { readSchemaSnapshot } from './schema-snapshot'; + +import type Database from 'better-sqlite3'; // In test mode each vitest worker gets an isolated in-memory DB so that // parallel forks can't race on the same file or share migration state. const isTest = readEnv().app.isTest; - -let dbPath: string; -if (isTest) { - dbPath = ':memory:'; -} else if (readEnv().db.trekDbFile) { - // Explicit DB file (used by the Playwright E2E harness to run against an - // isolated, throwaway database instead of the real data/travel.db). Purely - // additive — when unset the default path below is used exactly as before. - dbPath = readEnv().db.trekDbFile!; - const dir = path.dirname(dbPath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); -} else { - const dataDir = path.join(__dirname, '../../data'); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - } - dbPath = path.join(dataDir, 'travel.db'); -} +const dbPath = resolveDbPath(); let _db: Database.Database | null = null; +/** + * Opens the connection and applies the pragmas. It deliberately does NOT build + * the schema: migrations and seeding are MikroORM's job now and run from + * `db/orm.ts` during `buildApp()`, because the migrator is async and this + * function is called at module load. + * + * The one exception is test mode, where there is no `buildApp()` to hook into + * for the handful of suites that use this singleton directly. Those open a copy + * of a schema snapshot the vitest global setup migrated once for the whole run. + */ function initDb(): void { if (_db) { - try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - try { _db.close(); } catch (e) {} + try { + _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (e) {} + try { + _db.close(); + } catch (e) {} _db = null; } - _db = openDatabase(dbPath); + const snapshot = isTest ? readSchemaSnapshot() : null; + _db = openDatabase(snapshot ?? dbPath); // Ahead of the journal switch now: changing journal_mode needs an exclusive // lock, which a sibling process (reset-admin, the rotation script) may hold. _db.exec('PRAGMA busy_timeout = 5000'); @@ -51,15 +46,38 @@ function initDb(): void { if (dbPath !== ':memory:') { console.log(`[DB] journal_mode=${durability.journalMode}, synchronous=${durability.synchronous}`); } - - createTables(_db); - runMigrations(_db); - - runSeeds(_db); } initDb(); +/** + * The live better-sqlite3 handle, for the MikroORM dialect in `orm-driver.ts`. + * + * Deliberately not the `db` Proxy below: Kysely holds whatever it is given for + * the life of its client, and a Proxy would hide the swap a restore performs. + * The driver calls this on every connect so it always binds the current handle. + */ +function getRawConnection(): Database.Database { + if (!_db) throw new Error('Database connection is not available (restore in progress?)'); + return _db; +} + +/** + * Plan 4 Task 4 deleted `DatabaseService`/`DatabaseModule` and every `src/` + * importer of this Proxy is gone — but the export itself stays, deliberately: + * ~190 test files' `vi.mock('…/db/database', …)` factories return a + * `{ db: , … }` shape (`buildDbMock`/the ad-hoc equivalents), + * and their own `import { db as testDb } from '…/db/database'` line is typed + * against THIS file's real exports, not the runtime mock — Vitest's + * `vi.mock` swaps the module at runtime, but `tsc` resolves the import + * statically regardless. Deleting this export makes every one of those + * files fail `typecheck:tests` even though none of them use the exported + * Proxy in production. That test-harness idiom is Track B's own territory + * (5a/5b/5c's "e2e pattern"), not enumerated in this task's file set — + * left for a follow-up that swaps the idiom, not deleted out from under it. + * `src/` never imports it, so ESLint's `no-restricted-imports` shrink still + * holds this file to its permanent allow-list entry (rule 4) regardless. + */ const db = new Proxy({} as Database.Database, { get(_, prop: string | symbol) { if (!_db) throw new Error('Database connection is not available (restore in progress?)'); @@ -72,102 +90,81 @@ const db = new Proxy({} as Database.Database, { }, }); -if (readEnv().demo.enabled) { +/** + * Demo seeding, which used to run here at module load. + * + * It has to follow the schema now: `demo-seed.ts` inserts places against + * hard-coded category ids 1-9, and with `foreign_keys = ON` those rows only + * resolve once the category seeder has run. Called from the schema bootstrap in + * `db/orm.ts`, immediately after the seeders. + */ +// Plan 3i Task 3: `seedDemoData` converted onto `DemoRepository` (an +// EntityManager-backed repository), so it is `async` now and no longer takes +// the raw `db` handle — it resolves its EntityManager itself via +// `RequestContext.getEntityManager()`, which `runSchemaBootstrap`'s existing +// `withRequestContext(orm, () => runDemoSeed())` wrap (`db/orm.ts:59`) +// already populates by the time this runs. `runDemoSeed` awaits it now too: +// previously both were fully synchronous, so the try/catch below already +// caught everything before this function returned; without the `await` a +// rejected promise from `seedDemoData` would become an unhandled rejection +// instead of the `[Demo] Seed error:` log line this catch has always produced. +async function runDemoSeed(): Promise { + if (!readEnv().demo.enabled) return; try { const { seedDemoData } = require('../demo/demo-seed'); - seedDemoData(_db); + await seedDemoData(); } catch (err: unknown) { console.error('[Demo] Seed error:', err instanceof Error ? err.message : err); } } +/** + * Lets `db/orm.ts` rebuild the ORM's connection when this module swaps handles, + * without this module importing the ORM (which imports this module back). + */ +let onReinitialize: (() => Promise) | null = null; +function registerReinitializeHook(hook: () => Promise): void { + onReinitialize = hook; +} + function closeDb(): void { if (_db) { - try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - try { _db.close(); } catch (e) {} + try { + _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (e) {} + try { + _db.close(); + } catch (e) {} _db = null; console.log('[DB] Database connection closed'); } } -function reinitialize(): void { +/** + * Reopens the connection after a restore has swapped the file underneath us. + * + * Async now, because bringing a restored backup up to date means running the + * migrations, and the hook also has to rebuild the ORM's Kysely client — it + * cached the handle this function is about to replace. + */ +async function reinitialize(): Promise { console.log('[DB] Reinitializing database connection after restore...'); if (_db) closeDb(); initDb(); + if (onReinitialize) await onReinitialize(); console.log('[DB] Database reinitialized successfully'); } -interface PlaceWithCategory extends Place { - category_name: string | null; - category_color: string | null; - category_icon: string | null; -} +// getPlaceWithTags/canAccessTrip/isOwner and the TripAccess/PlaceWithTags +// interfaces lived here through Plan 3c Task 0a's async sweep. Task 0b moved +// their bodies onto TripsRepository.findAccessible/isOwner and +// PlacesRepository.findWithTagsAndRatings — db/repositories/Trips.repository.ts's +// TripAccess is now the single source. Plan 4 Task 4 deleted `DatabaseService` +// (its last `src/` consumer) once every caller moved onto a repository, the +// `UnitOfWork`, or (for the two whole-database-file statements no +// entity/repository call can express) `MaintenanceRepository`/ +// `DemoRepository`'s own `connection.execute()`. The `db` Proxy itself stays +// exported — see its own docstring above for why (~190 test files' static +// typecheck, not a production `src/` need). -interface PlaceWithTags extends Place { - category: { id: number; name: string; color: string; icon: string } | null; - tags: Tag[]; - ratings: { user_id: number; username: string; avatar: string | null; rating: number }[]; - rating_avg: number | null; - rating_count: number; -} - -function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { - const place = db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p - LEFT JOIN categories c ON p.category_id = c.id - WHERE p.id = ? - `).get(placeId) as PlaceWithCategory | undefined; - - if (!place) return null; - - const tags = db.prepare(` - SELECT t.* FROM tags t - JOIN place_tags pt ON t.id = pt.tag_id - WHERE pt.place_id = ? - `).all(placeId) as Tag[]; - - // Collaborative ratings (#1435): every voter with username/avatar for the - // who-voted tooltip; the displayed value is the average. - const ratings = db.prepare(` - SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr - JOIN users u ON pr.user_id = u.id - WHERE pr.place_id = ? ORDER BY pr.created_at - `).all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[]; - - return { - ...place, - category: place.category_id ? { - id: place.category_id, - name: place.category_name!, - color: place.category_color!, - icon: place.category_icon!, - } : null, - tags, - ratings, - rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null, - rating_count: ratings.length, - }; -} - -interface TripAccess { - id: number; - user_id: number; - currency: string | null; -} - -function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { - return db.prepare(` - SELECT t.id, t.user_id, t.currency 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) as TripAccess | undefined; -} - -function isOwner(tripId: number | string, userId: number): boolean { - return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); -} - - -export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; -export type { TripAccess, PlaceWithTags }; +export { db, closeDb, reinitialize, getRawConnection, registerReinitializeHook, runDemoSeed }; diff --git a/server/src/db/db-path.ts b/server/src/db/db-path.ts new file mode 100644 index 000000000..6d9eb86f9 --- /dev/null +++ b/server/src/db/db-path.ts @@ -0,0 +1,34 @@ +import { readEnv } from '../app-config'; + +import fs from 'fs'; +import path from 'path'; + +/** + * The SQLite file this process talks to. + * + * Shared by `database.ts` (which opens the connection) and `mikro-orm.config.ts` + * (which needs the same target for the CLI and for the Nest-owned ORM). Kept in + * one place because a config that names a different file than the connection is + * the kind of mistake that only shows up as an empty database. + * + * Creates the containing directory as a side effect, exactly as the original + * inline block in `database.ts` did. + */ +export function resolveDbPath(): string { + // In test mode each vitest worker gets an isolated in-memory DB so that + // parallel forks can't race on the same file or share migration state. + if (readEnv().app.isTest) return ':memory:'; + + const explicit = readEnv().db.trekDbFile; + if (explicit) { + // Explicit DB file (used by the Playwright E2E harness to run against an + // isolated, throwaway database instead of the real data/travel.db). + const dir = path.dirname(explicit); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return explicit; + } + + const dataDir = path.join(__dirname, '../../data'); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + return path.join(dataDir, 'travel.db'); +} diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts new file mode 100644 index 000000000..e3859062f --- /dev/null +++ b/server/src/db/dialect/sql-functions.ts @@ -0,0 +1,981 @@ +import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core'; +import { SqlitePlatform } from '@mikro-orm/sql'; +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. + * + * Every helper takes the MikroORM `Platform` the caller's EntityManager is + * running on (`em.getPlatform()`) plus an already-qualified column reference + * (`'t.created_at'`), and dispatches on that platform at runtime. TREK ships + * one binary and the database is chosen by configuration, so the dialect is a + * runtime property of the live connection, not a build-time swap: when the + * Postgres driver arrives this file grows a second branch and no repository + * changes. An unknown platform fails closed rather than guessing a spelling. + * + * Values are never interpolated — the day count in `dateAdd` is checked to be + * an integer before it is spelled. + */ + +function column(ref: string): string { + if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i.test(ref)) { + throw new Error(`sql-functions: not a column reference: ${ref}`); + } + return ref; +} + +function unsupported(platform: Platform): never { + throw new Error(`sql-functions: no implementation for platform ${platform.constructor.name}`); +} + +/** The calendar date (`YYYY-MM-DD`) of a timestamp column. */ +export function dateOf(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`date(${column(ref)})`); + return unsupported(platform); +} + +/** The timestamp column shifted by whole days, as a calendar date. */ +export function dateAdd(platform: Platform, ref: string, days: number): RawQueryFragment { + if (!Number.isInteger(days)) throw new Error(`sql-functions: dateAdd needs an integer day count, got ${days}`); + if (platform instanceof SqlitePlatform) { + const sign = days < 0 ? '-' : '+'; + return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`); + } + return unsupported(platform); +} + +/** The database clock, in the same text the column defaults produce. */ +export function currentTimestamp(platform: Platform): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('CURRENT_TIMESTAMP'); + return unsupported(platform); +} + +/** + * A bare reference to another column of the same row, for a filter or update + * value that must compare against or combine with a sibling column instead + * of a bound parameter (e.g. `used_count < max_uses` — `InviteTokensRepository + * .incrementUsedCount`'s capacity guard). Portable across dialects on its own, + * but still routed through this file (not spelled with `raw()` in a + * repository) so every escape hatch stays in one place ESLint's + * `no-restricted-syntax` rule for `src/db/repositories/**` can enforce. + */ +export function columnRef(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(column(ref)); + return unsupported(platform); +} + +/** A column shifted by a signed integer amount: `+ n` / `- n`. */ +export function columnIncrementedBy(platform: Platform, ref: string, amount: number): RawQueryFragment { + if (!Number.isInteger(amount)) { + throw new Error(`sql-functions: columnIncrementedBy needs an integer amount, got ${amount}`); + } + if (platform instanceof SqlitePlatform) { + const sign = amount < 0 ? '-' : '+'; + return raw(`${column(ref)} ${sign} ${Math.abs(amount)}`); + } + return unsupported(platform); +} + +/** + * A case-insensitive column reference, for use as a filter's object key: + * `{ [lower(platform, 'email')]: }` produces + * `WHERE LOWER(email) = `. + * + * **SQLite's `LOWER()` is ASCII-only; JavaScript's `String.prototype + * .toLowerCase()` is full-Unicode.** They do NOT agree on every input this + * codebase stores — a locally-registered e-mail can carry any non-whitespace + * character (`EMAIL_REGEX`), including non-ASCII letters (program rule 18; + * Plan 3b Task 7 review, H1: `JOSÉ@x.com` proved a live login lockout). Two + * value-side shapes are legitimate, and they are NOT interchangeable: + * + * - `LOWER(col) = LOWER(?)` — pair with `lowerParam()` below, binding the + * RAW value. Both sides are folded by the SAME engine (SQLite's), so they + * agree on every input. This is the shape for every legacy + * `LOWER(col) = LOWER(?)` statement (AU9, AU12, UP5, UP6, O12). + * - `LOWER(col) = ?` — pair with a value the CALLER has already lowered in + * JS (`value.toLowerCase()`) as a plain string, matching a legacy + * `LOWER(col) = ?` statement exactly (only O4 today — + * `OidcService.findOrCreateUser` JS-lowers the email once upfront and the + * legacy statement it replaced never re-lowered it in SQL). Do not use + * this shape for a value the caller has not already lowered. + * + * Typed `RawQueryFragment & symbol` (not the bare `RawQueryFragment` the + * other helpers in this file return): TypeScript only accepts a + * `string | number | symbol` as a computed property name, and a raw + * fragment is only usable as an object key because `raw()`'s default + * generic already brands it with a `[Symbol.toPrimitive]`. The other + * helpers here are only ever used as VALUES, where that brand isn't needed. + */ +export function lower(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`); + return unsupported(platform); +} + +/** + * The value-side twin of `lower()`: `LOWER(?)` bound with the RAW, + * untransformed value, for the right-hand side of a `lower(platform, col)` + * filter key. Together they render `LOWER() = LOWER(?)`, + * byte-for-byte a legacy `LOWER(col) = LOWER(?)` statement, because SQLite + * folds BOTH sides with the same (ASCII-only) engine — proven directly + * against `better-sqlite3`: `SELECT LOWER(?)` on `'JOSÉ@x.com'` yields + * `'josÉ@x.com'` (only the ASCII letters fold; the `É` is untouched), the + * same transform SQLite would apply to a stored column. Do NOT pre-lower + * the value in JS before calling this (program rule 18) — that reintroduces + * the mixed-engine bug this helper exists to close. + */ +export function lowerParam(platform: Platform, value: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('LOWER(?)', [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 0b (R6) — no consumer yet: Tasks 1–8 wire these in as each +// converts the statement that needs them (`place-shadow.service.ts:192`, +// `places.service.ts:642,651-652`, the ~16 `COALESCE` sites across +// `days`/`assignments`/`trip-members`/`places`). Added here, with their own +// SQLF-0xx tests pinning the SQLite text against a raw statement, so a later +// task imports a tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * `datetime('now', '-N days')` — the SQLite clock shifted backwards by whole + * days, as TEXT (not a stored column's value — `dateAdd` above is the wrong + * shape for this: it shifts a *column*, not "now"). `days` is validated to be + * a non-negative integer and spelled directly into the fragment rather than + * bound: the legacy statement (`place-shadow.service.ts:192`) binds the + * WHOLE modifier string (`'-30 days'`) as one parameter, and MikroORM's raw + * fragments do not accept a placeholder inside the modifier argument of a + * SQLite date/time function — `datetime('now', ?)` with `'-30 days'` bound + * does work in raw SQLite, but composing that shape through a `raw()` + * fragment used as a QueryBuilder value needs the modifier already in the + * SQL text, the same way `dateAdd` above spells its own day count. + */ +export function nowMinusDays(platform: Platform, days: number): RawQueryFragment { + if (!Number.isInteger(days) || days < 0) { + throw new Error(`sql-functions: nowMinusDays needs a non-negative integer day count, got ${days}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${days} days')`); + return unsupported(platform); +} + +/** + * `TRIM()`, standalone. **Does NOT compose with `lower()`** to build + * `LOWER(TRIM(name))` (Task 0b review M3): `lower()`'s `ref` argument goes + * through `column()`, whose regex rejects anything but a bare + * `alias.column` reference — `TRIM(name)` fails it — and even if it didn't, + * `coalesce()`'s own docstring in this file documents that a + * `RawQueryFragment` cannot be nested inside another one (`raw()`'s + * `[Symbol.toPrimitive]` only implements the `'string'` coercion hint, which + * is the object-key position, not template-literal interpolation). Use + * `lowerTrim()` below for the composed shape. + */ +export function trim(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`TRIM(${column(ref)})`); + return unsupported(platform); +} + +/** + * `LOWER(TRIM())` as one fragment — the shape `places.service.ts:642` + * (`lower(trim(name))`, PL26) actually needs; `lower()`/`trim()` above + * cannot be composed to build it (see `trim()`'s docstring, Task 0b review + * M3). Typed `RawQueryFragment & symbol`, the same brand `lower()` carries, + * for use as a filter KEY: `{ [lowerTrim(platform, 'name')]: lowerParam(...) }`. + * SQLite's `LOWER()` is ASCII-only (program rule 18) — unchanged by the + * `TRIM()`. + */ +export function lowerTrim(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`LOWER(TRIM(${column(ref)}))`); + return unsupported(platform); +} + +/** + * `COALESCE(, )` — both sides column references, for the + * `COALESCE(a, b)` SELECT-projection shape (`days.service.ts:93-94`, + * `assignments.service.ts:103-104`, `trip-members.service.ts:116-143`, …). + * + * Not composed by nesting a `RawQueryFragment` inside this one: MikroORM's + * `RawQueryFragment[Symbol.toPrimitive]` only returns a usable value for the + * `'string'` coercion hint (object-key position) — a template-literal + * `${fragment}` embed uses the `'default'` hint and throws + * (`node_modules/@mikro-orm/core/utils/RawQueryFragment.js`, verified + * directly, not assumed). So `coalesce`'s second argument is a second + * `column()`-validated identifier, spelled into the same fragment as the + * first — two columns, one statement, matching `lower`/`lowerParam`'s + * two-function split for the same reason (a filter KEY vs a bound VALUE + * are different shapes). + */ +export function coalesce(platform: Platform, ref: string, fallbackRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ${column(fallbackRef)})`); + return unsupported(platform); +} + +/** + * `COALESCE(, ?)` — the value-side twin of `coalesce()` above, for the + * `COALESCE(col, ?)` UPDATE-SET shape (`places.service.ts:344,351,363,…`). + * `value` is bound as a genuine parameter, never interpolated. + * + * Typed `RawQueryFragment & symbol` (widened from a bare `RawQueryFragment` + * in Plan 3d Task 0, R3 — `_shared/reservation-visibility.ts`'s + * `publicReservationCondition` needs `COALESCE(alias.ingest_state, 'live')` + * as a `.where({...})` filter KEY, `{ [coalesceParam(...)]: { $ne: 'staged' + * } }`, the same brand `lower()`/`lowerTrim()`/`castInteger()` carry for + * that use): every existing UPDATE-SET caller (`Places.repository.ts`'s six + * `coalesceParam(...)` assignments into a loosely-typed `data` object) keeps + * typechecking unchanged — `RawQueryFragment & symbol` is assignable + * anywhere a plain `RawQueryFragment` is expected, a strict widening with no + * behaviour change (the function body is untouched). + */ +export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ?)`, [value]); + 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`: + * `{ [absDifference(platform, 'lat', targetLat)]: { $lte: tolerance } }` + * renders `WHERE ABS(lat - ?) <= ?`, matching `places.service.ts:651-652`'s + * `abs(lat - ?) <= ?` (and the sibling `lng` comparison at `:652`) exactly. + * Typed `RawQueryFragment & symbol`, same reason `lower()` is: only a + * `string | number | symbol` is a valid computed property name, and the + * brand only lives on that typing for values meant to be used as a key. + */ +export function absDifference(platform: Platform, ref: string, value: number): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`ABS(${column(ref)} - ?)`, [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 1 — aggregate SELECT projections (`PlaceShadowPicksRepository +// .totals`/`countBySource`/`countByLiveRank`, `place-shadow.service.ts:144, +// 149, 157`). `alias` is a hand-written literal at every call site today +// (never user input), validated the same way `column()` validates a column +// reference, so a future caller cannot smuggle SQL through it either. +// --------------------------------------------------------------------------- + +function alias(name: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(name)) { + throw new Error(`sql-functions: not an alias: ${name}`); + } + return name; +} + +/** `COUNT(*) as `, for a single-row totals read or a `GROUP BY` count column. */ +export function countAll(platform: Platform, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`COUNT(*) as ${alias(aliasName)}`); + return unsupported(platform); +} + +/** `MIN() as `. */ +export function minOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`MIN(${column(ref)}) as ${alias(aliasName)}`); + return unsupported(platform); +} + +/** `MAX() as `. */ +export function maxOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`MAX(${column(ref)}) as ${alias(aliasName)}`); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 6 — `TripMembersRepository.listWithUserAndInviter` +// (`trip-members.service.ts:117`, TM2): the `role` column, a `CASE WHEN` +// comparing the joined user's id against the trip's owner id. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN = ? THEN ? ELSE ? END`, for use as a `.select()` value + * (chain `.as(aliasName)` at the call site, the same way `coalesce()`'s + * callers do — this file does not bake the alias into the SQL text itself + * for this one, unlike `countAll`/`minOf`/`maxOf`, because `.as()` already + * covers it and a `value`-bearing fragment cannot reuse `alias()`'s bare + * identifier validation the same way an aggregate's literal alias does). + * `value`/`whenTrue`/`whenFalse` are all passed through `raw()`'s own + * parameter escaping, never spliced into the SQL text by this function — + * even though today's only caller (TM2's owner/member role label) passes + * fixed literal strings, parity is by VALUE, not by whether the string + * happens to be a source-code literal at the call site. (Task 6 review's + * I1: downstream, MikroORM still formats the FINAL query with every + * parameter inlined as an escaped literal in the SQL text it hands + * better-sqlite3 — not as a separate bound-params array — so a non-finite + * `value` renders as the bare token `NaN`/`Infinity` and fails at PREPARE + * time regardless of this function's own escaping; callers must guard a + * non-finite `value` before calling.) + */ +export function caseWhenEquals(platform: Platform, ref: string, value: number, whenTrue: string, whenFalse: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 1 fix round (review M1) — `PlaceShadowPicksRepository +// .countBySource` (PS5): `.orderBy()` on `countAll()`'s aliased column throws +// ("Trying to query by not existing property" — `raw()`'s alias carries no +// literal type for `ExtractRawAliases` to register), and sorting the fetched +// rows in JS is NOT equivalent to the legacy `ORDER BY count DESC`: SQLite's +// own sorter is not stable, so on a tie it orders descending by GROUP BY key, +// while `Array.prototype.sort` (stable) leaves ties in the rows' incoming +// (ascending) order — verified directly (five sources, two tied pairs). +// --------------------------------------------------------------------------- + +/** + * `COUNT(*)` as an ORDER BY EXPRESSION (not an alias — `.orderBy()` cannot + * order by `countAll()`'s aliased column; ordering by the expression itself + * works and reproduces the legacy `ORDER BY count DESC` tie order exactly). + * Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()` + * carry, so it is usable as an `.orderBy()` object key: + * `.orderBy({ [countAllRef(platform)]: 'desc' })`. + */ +export function countAllRef(platform: Platform): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw('COUNT(*)'); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 0 (R6) — no consumer yet: Tasks 2/4 wire these in as each +// converts the statement that needs them (`reservations.service.ts` RS11 and +// RS20 `listUpcoming`, `reservation-visibility.ts` RV2, DY23 in +// `days.service.ts`). Added here, with their own SQLF-0xx tests pinning the +// SQLite text against a raw statement on seeded rows, so Tasks 1-6 import a +// tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * `GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` — "does this + * column's TEXT value start with an ISO calendar date (`YYYY-MM-DD`)?", the + * legacy `DATED` constant (`reservations.service.ts:120`: + * `` `r.reservation_time GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` ``, + * used at `:453-454` inside RS20's two `CASE WHEN` arms). Spelled with + * SQLite's `[0-9]` character-class glob syntax, NOT the plan's shorthand + * `'????-??-??*'` — verified directly against the legacy source, not + * assumed: a `?` in SQLite GLOB matches any single character (letters + * included), so `????-??-??*` is a strictly WIDER, non-equivalent pattern + * (it would also match `abcd-ef-gh...`); only the digit-class form matches + * the legacy's actual behaviour byte-for-byte. + * + * A full boolean expression, not a bare value — pair with a `CASE WHEN` + * builder or a QB/Kysely raw condition, never nest inside another raw + * fragment (see `coalesce()`'s docstring on why nesting doesn't work). + * Kysely has no `GLOB` comparison operator (`operator-node.d.ts`'s + * `ComparisonOperator` union omits it — verified against the installed + * 0.29.6 typings, per the inventory's §19 MikroORM/Kysely capability table) + * — this helper's SQLite-only reach is therefore the only portable spelling + * for this shape until a second platform is added. + */ +export function startsWithIsoDate(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw(`${column(ref)} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`); + } + return unsupported(platform); +} + +/** + * `substr(, [, ])`, 1-based on both ends — the legacy + * shape at `reservations.service.ts:453` (`substr(r.reservation_time, 1, + * 10)`, three-arg), `:454` (`substr(r.reservation_time, 12)`, two-arg — "from + * position 12 to the end of the string") and `days.service.ts` DY23 + * (`SUBSTR(reservation_time, 11)`, same two-arg shape, spelled uppercase in + * that statement — SQL function names are case-insensitive, so this helper's + * lowercase spelling is behaviourally identical). `length` is optional, + * matching SQLite's own two-arg `substr(X, Y)` form (everything from `Y` to + * the end) — passing it renders the three-arg form instead. `start` and + * `length` are validated integers and spelled directly (not bound): they are + * always source-code constants at every known call site (`1`, `10`, `11`, + * `12`), never user input, the same trust boundary `dateAdd`'s day count and + * `columnIncrementedBy`'s amount already rely on in this file. + */ +export function substring(platform: Platform, ref: string, start: number, length?: number): RawQueryFragment { + if (!Number.isInteger(start) || start < 1) { + throw new Error(`sql-functions: substring needs a 1-based integer start, got ${start}`); + } + if (length !== undefined && (!Number.isInteger(length) || length < 0)) { + throw new Error(`sql-functions: substring needs a non-negative integer length, got ${length}`); + } + if (platform instanceof SqlitePlatform) { + if (length === undefined) return raw(`substr(${column(ref)}, ${start})`); + return raw(`substr(${column(ref)}, ${start}, ${length})`); + } + return unsupported(platform); +} + +/** + * A part of a `concat()` expression: either a column reference (rendered + * through `column()`, the same identifier validation every other helper in + * this file applies) or a plain value bound as a genuine parameter (`?`) — + * never interpolated, even for what today's only legacy caller spells as a + * literal (`'T'` in `d.date || 'T' || a.check_in`, + * `reservations.service.ts:475, :493`): a bound constant and an interpolated + * one produce byte-identical SQLite behaviour for a plain string, and + * binding is the shape every other value-side helper in this file already + * prefers (`coalesceParam`, `lowerParam`, `absDifference`). + */ +export type ConcatPart = { column: string } | { value: string }; + +/** + * ` || || …` — SQLite (and Postgres) string concatenation, the + * legacy shape at `reservations.service.ts:475, :493` + * (`d.date || 'T' || a.check_in`, RS20's two check-in/check-out arms) and + * `days.service.ts` DY23 (`:date || SUBSTR(reservation_time, 11)`, inside an + * `UPDATE … SET`). Needs at least two parts — a one-part "concatenation" is + * a caller bug, not a legitimate call. Each part is a `ConcatPart` (see its + * own docstring) rather than a bare string, so a column reference and a + * bound literal can never be confused with each other the way an untyped + * `...parts: string[]` would invite (a plain string could mean either). + * + * Deliberately does NOT accept a nested `RawQueryFragment` as a part (e.g. + * the output of `substring()` above) — the same limitation `coalesce()`'s + * docstring documents: `RawQueryFragment`'s `[Symbol.toPrimitive]` only + * implements the `'string'` coercion hint (the object-key position), not the + * `'default'` hint a template-literal embed or an array-join would need, so + * composing `concat(platform, substring(...), ...)` throws at the `raw()` + * boundary rather than rendering silently wrong SQL. A caller that needs + * `SUBSTR(...)` as one side of a `||` builds that one statement directly + * (Kysely's `'||'` binary operator, per the inventory's §17a/§19), the same + * way RS20's own CASE/CTE shape is built outside this file's helpers. + */ +export function concat(platform: Platform, ...parts: readonly ConcatPart[]): RawQueryFragment { + if (parts.length < 2) { + throw new Error(`sql-functions: concat needs at least two parts, got ${parts.length}`); + } + if (platform instanceof SqlitePlatform) { + const bindings: string[] = []; + const sql = parts + .map((part) => { + if ('column' in part) return column(part.column); + bindings.push(part.value); + return '?'; + }) + .join(' || '); + return raw(sql, bindings); + } + return unsupported(platform); +} + +/** + * `CAST(AS INTEGER)` — the legacy shape at `reservations.service.ts:471, + * :489` (inside RS20's correlated `stay_first_reservation_id`/title + * subqueries: `CAST(res.accommodation_id AS INTEGER) = a.id`) and + * `reservation-visibility.ts:106-107` (RV2's `publicStaySql`: + * `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id`). Exists specifically + * for `reservations.accommodation_id`, a TEXT column holding integer ids + * with no FK (§18.1 of the inventory — some rows read back as `"14.0"`): + * casting it to INTEGER before comparing against a genuine INTEGER column + * (`day_accommodations.id`) applies numeric affinity to BOTH sides, matching + * `"14"` and `"14.0"` alike, the same way the legacy statement does. + * + * Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()` + * carry: both known legacy call sites compare the cast against ANOTHER + * COLUMN, not a bound value (`= a.id`, `= ${alias}.id`) — the shape is + * `{ [castInteger(platform, 'vr.accommodation_id')]: columnRef(platform, + * 'a.id') }`, pairing this helper's filter-KEY brand with `columnRef()`'s + * existing VALUE-side column reference. The brand also lets it appear in a + * `.select()` projection via `.as(alias)` (the `lowerTrim`/SQLF-027 dual-use + * precedent) for a caller that only needs the cast INTEGER value itself. + */ +export function castInteger(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`CAST(${column(ref)} AS INTEGER)`); + return unsupported(platform); +} + +/** + * `ABS(JULIANDAY() - JULIANDAY(?))` — the legacy shape at + * `reservations.service.ts:307` (RS11, `resolveDayIdFromTime`'s + * nearest-day fallback: `ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, + * date ASC LIMIT 1`), which clamps an imported booking's date to the + * closest day row in the trip when no exact match exists. `isoDate` is + * bound as a genuine parameter (never interpolated), matching the legacy + * statement's own bind. Distinct from `absDifference()` above: + * `absDifference` is `ABS(- ?)` on plain NUMERIC values (for a + * lat/lng tolerance filter); this helper compares two `JULIANDAY(...)` + * calendar values. Typed `RawQueryFragment & symbol`, the same brand + * `countAllRef()` carries, so it is usable as an `.orderBy()` object key — + * `.orderBy({ [dayDistance(platform, 'date', isoDate)]: 'asc' })` — + * matching the legacy's own `ORDER BY ABS(JULIANDAY(...) - JULIANDAY(?)) ASC` + * shape exactly (an aliased column cannot be ordered by, the same + * `ExtractRawAliases` limitation `countAllRef`'s own docstring / SQLF-024's + * comment documents; ordering by the EXPRESSION itself is what works). + */ +export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 0 review (H1/L1) — the five helpers above return a MikroORM +// `RawQueryFragment`. Handed into a Kysely statement, a `RawQueryFragment` +// stringifies through its own `[Symbol.toPrimitive]('string')` coercion into +// the literal text `?` (its parameter-placeholder marker, meant for the +// MikroORM QueryBuilder's OWN parameter interpolation, not Kysely's) — Kysely +// binds it as an actual `?` value parameter and the statement fails at +// execution (verified directly, not assumed: `db.selectFrom(...).where(..., +// startsWithIsoDate(platform, 'col'))` throws `SqliteError: near "?": syntax +// error` the first time it runs). Task 2 absorbed this finding (its report, +// H1/L1) — these are genuinely separate functions, not a wrapper over the +// ones above, because a Kysely `Expression` and a MikroORM `RawQueryFragment` +// are different types with no conversion between them. Each takes the +// caller's own `ExpressionBuilder` (the same "no consumer-facing +// generic DB" shape `_shared/reservation-visibility.ts`'s `publicStayExists` +// documents is the only one that survives `tsc` for a shared Kysely helper — +// unlike that one, these ARE fully generic over ``, because they only +// ever call `eb.fn`/`eb.cast`/`eb(...)`, none of which hit the QueryBuilder +// method-chaining inference limit that forced `publicStayExists` to fix its +// shape). Platform-dispatched and fail-closed like every helper above; each +// has an SQLF-0xx test pinning its compiled `{sql, parameters}` AND a +// raw-statement equivalence on seeded rows (`tests/unit/db/dialect/sql-functions.test.ts`). +// --------------------------------------------------------------------------- + +/** + * The Kysely-expression twin of {@link startsWithIsoDate}: ` GLOB + * '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`, spelled through SQLite's + * function form (`glob(X, Y)` is exactly `Y GLOB X` — the inventory's own + * §19 finding, since Kysely's `ComparisonOperator` union has no `GLOB` + * entry). Returns a boolean `Expression`, usable in a `.where()` + * or inside a `CASE WHEN` condition. Same digit-class pattern as the + * MikroORM version — NOT the wider `'????-??-??*'` shorthand (a `?` in + * SQLite GLOB matches any character, letters included). + */ +export function startsWithIsoDateKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) { + return eb.fn('glob', [eb.val('[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'), ref]); + } + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link substring}: `substr(, + * [, ])`, 1-based on both ends. `start`/`length` are + * validated integers, the same guard `substring()` above applies, and bound + * as genuine parameters here (`eb.val`) rather than spelled into the SQL + * text — Kysely's `fn()` takes `ReferenceExpression`s, not a raw text + * fragment, so there is no equivalent "spell a constant into the SQL" + * shape to match; a bound integer literal renders identically. + */ +export function substringKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, + start: number, + length?: number, +): ExpressionWrapper { + if (!Number.isInteger(start) || start < 1) { + throw new Error(`sql-functions: substringKysely needs a 1-based integer start, got ${start}`); + } + if (length !== undefined && (!Number.isInteger(length) || length < 0)) { + throw new Error(`sql-functions: substringKysely needs a non-negative integer length, got ${length}`); + } + if (platform instanceof SqlitePlatform) { + const args: ReferenceExpression[] = length === undefined + ? [ref, eb.val(start)] + : [ref, eb.val(start), eb.val(length)]; + return eb.fn('substr', args); + } + return unsupported(platform); +} + +/** A part of a `concatKysely()` expression — a column reference, a bound value, or a nested Kysely `Expression` (composes freely, unlike `concat()`'s MikroORM `RawQueryFragment` form). */ +export type KyselyConcatPart = + | { column: StringReference } + | { value: string } + | { expression: Expression }; + +/** + * The Kysely-expression twin of {@link concat}: ` || || …`, + * SQLite's/Postgres's string concatenation operator, built through the + * expression builder's own binary-operator call (`eb(lhs, '||', rhs)`) so + * it composes with {@link substringKysely}/{@link castIntegerKysely} — + * exactly the composition `concat()`'s MikroORM form cannot do (its + * docstring explains why). + */ +export function concatKysely( + platform: Platform, + eb: ExpressionBuilder, + ...parts: readonly KyselyConcatPart[] +): ExpressionWrapper { + if (parts.length < 2) { + throw new Error(`sql-functions: concatKysely needs at least two parts, got ${parts.length}`); + } + if (!(platform instanceof SqlitePlatform)) return unsupported(platform); + const operand = (part: KyselyConcatPart): Expression => + 'column' in part ? eb.ref(part.column).$castTo() : 'value' in part ? eb.val(part.value) : part.expression; + let acc: Expression = operand(parts[0]!); + for (const part of parts.slice(1)) { + acc = eb(acc, '||', operand(part)); + } + // The loop above always runs at least once (the `parts.length < 2` guard + // above throws otherwise), so `acc` is always the result of the `eb(...)` + // binary-operator call — a real `ExpressionWrapper`, not the bare + // `Expression` interface `operand()`'s own return type declares. + return acc as ExpressionWrapper; +} + +/** + * The Kysely-expression twin of {@link castInteger}: `CAST( AS + * INTEGER)`, for `reservations.accommodation_id` (TEXT) compared against an + * INTEGER column inside a Kysely statement (RS20/RV2's join shape — a + * correlated `EXISTS`/scalar-subquery context the MikroORM QueryBuilder + * cannot express, per the inventory's own T6 ruling). + */ +export function castIntegerKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) return eb.cast(ref, 'integer'); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (`collateNoCase`, SH9's +// `school-holidays.service.ts#checkName` duplicate-name guard) and Task 4 +// (`nowMinusHours`, RJ4's `reminder-jobs.service.ts#todoTick` +// `datetime('now', '-20 hours')`) wire these in when each converts the +// statement that needs them. `lowerTrimParam` (AT31's value side, atlas's +// bucket-list dedup) is Task 1's. Added here, with their own SQLF-0xx tests +// pinning the SQLite text against a raw statement on seeded rows, so each +// task imports a tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * A case-insensitive column reference via SQLite's `NOCASE` collating + * sequence, for use as a filter's object key — the same `{ [collateNoCase( + * platform, 'name')]: value }` shape `lower()` documents, rendering `WHERE + * name COLLATE NOCASE = `. The legacy statement this replaces + * (`school-holidays.service.ts:50`, SH9) spells the COLLATE on the VALUE + * side instead (`name = ? COLLATE NOCASE`) — textually different, but SQLite + * resolves a binary comparison's collating sequence from the FIRST operand + * that carries an explicit `COLLATE`, scanning left to right, so `col + * COLLATE NOCASE = ?` and `col = ? COLLATE NOCASE` select the same collation + * (NOCASE) for the whole comparison either way — verified directly + * (SQLF-060: both forms select the identical row set on a mixed-case + * fixture), not assumed from the operator's documented precedence alone. + * + * **Not the same helper as `lower()`, and not reusable in its place.** + * SQLite's built-in `NOCASE` collation is documented to fold ONLY the 26 + * ASCII letters, exactly like `LOWER()`'s own ASCII-only fold (program rule + * 18) — verified directly against `better-sqlite3` (SQLF-061): on every + * non-ASCII fixture this file's own `LOWER()` test (SQLF-014) already + * carries, `x = y COLLATE NOCASE` and `LOWER(x) = LOWER(y)` agree, letter for + * letter — neither engine folds a non-ASCII case pair. So `collateNoCase` + * does NOT diverge from `lower()` on any input constructed here; it is a + * NEW helper anyway (per R9's own instruction) because the two are + * textually different SQL constructs matching two textually different + * legacy statements (`COLLATE NOCASE` vs `LOWER(...)`), and because a + * `COLLATE`-based comparison lets SQLite use an index on the un-wrapped + * column in a way `LOWER(col) = ?` cannot — a real, if not row-visible, + * difference between the two shapes. + */ +export function collateNoCase(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`${column(ref)} COLLATE NOCASE`); + return unsupported(platform); +} + +/** + * `datetime('now', '-N hours')` — the hour-granularity sibling of + * `nowMinusDays()` above, for `reminder-jobs.service.ts#todoTick`'s + * `datetime('now', '-20 hours')` (RJ4) — 20 hours is not expressible as + * `nowMinusDays` with a fractional day. Same validation shape as + * `nowMinusDays` (a non-negative integer, spelled directly into the + * fragment rather than bound — MikroORM's raw fragments do not accept a + * placeholder inside a SQLite date/time function's modifier argument, the + * same reasoning `nowMinusDays`'s own docstring gives). + */ +export function nowMinusHours(platform: Platform, hours: number): RawQueryFragment { + if (!Number.isInteger(hours) || hours < 0) { + throw new Error(`sql-functions: nowMinusHours needs a non-negative integer hour count, got ${hours}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${hours} hours')`); + return unsupported(platform); +} + +/** + * The value-side twin of `lowerTrim()`: `LOWER(TRIM(?))` bound with the RAW, + * untransformed value — confirmed genuinely missing by reading this file's + * full export list before adding it (R9), not assumed from the inventory's + * own flag. Pairs with `lowerTrim()` as a filter KEY for the composed + * `LOWER(TRIM()) = LOWER(TRIM(?))` shape + * `atlas.service.ts:876-877`'s AT31 needs (`lower(trim(name)) = + * lower(trim(?))`), the same "bind the raw value, let SQL apply the SAME + * transform to both sides" reasoning `lowerParam()`'s own docstring gives — + * do NOT pre-trim/pre-lower the value in JS before calling this (program + * rule 18: mixing engines is the bug this pairing exists to avoid). + */ +export function lowerTrimParam(platform: Platform, value: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('LOWER(TRIM(?))', [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3g Task 0 (R1) — no consumer yet: Task 1 (`getJourneyFull`'s gallery +// read) and Task 3 (`getPublicJourney`'s gallery read) each fold this into +// their own rebuild of `journey-gallery-order.ts`'s `GALLERY_CHRONOLOGICAL_ORDER` +// text (a `COALESCE(NULLIF(tp.taken_at, ''), , +// )` ORDER BY, worked out in full in Task 0's report for both to paste +// in verbatim, neither re-deriving it). Added here, Kysely-only (no MikroORM +// `raw()` sibling): the shape it exists for is genuinely Kysely-only (a +// correlated scalar subquery cannot be expressed through the MikroORM +// QueryBuilder — the same "T6 ruling" `castIntegerKysely`'s own docstring +// cites for RS20/RV2), and nothing else in this plan's inventory needs a +// MikroORM-form epoch-to-ISO conversion (§6: "no other strftime/julianday +// sites found elsewhere in this cluster"). A raw twin can be added later, +// same as every other helper here, the moment a real consumer needs one. +// --------------------------------------------------------------------------- + +/** + * `strftime('%Y-%m-%dT%H:%M:%SZ', / 1000, 'unixepoch')` — the last of + * `GALLERY_CHRONOLOGICAL_ORDER`'s three fallback tiers (R1): when a gallery + * photo has neither a capture time (`tp.taken_at`) nor a linked entry to + * borrow a date from, the moment it was added is what is left to order by. + * `` is an epoch-MILLISECONDS column (`journey_photos.created_at`, like + * every other `*_at` epoch column in this codebase) — SQLite's `unixepoch` + * modifier expects whole SECONDS, so the division is load-bearing, not + * decorative, and matches the legacy text's own `gp.created_at / 1000` + * exactly. SQLite's `/` on two integers truncates instead of rounding; that + * sub-second loss is the legacy statement's own behaviour, preserved here + * rather than "fixed" into a float divide that would render a different ISO + * string for any timestamp not an exact multiple of 1000ms. + * + * Named for what it computes, not the SQL function it spells — matching + * every other `*Kysely` twin in this file (`castIntegerKysely`, not + * `castKysely`; `startsWithIsoDateKysely`, not `globKysely`). + */ +export function unixEpochToIsoKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) { + const seconds = eb(ref, '/', eb.val(1000)); + return eb.fn('strftime', [eb.val('%Y-%m-%dT%H:%M:%SZ'), seconds, eb.val('unixepoch')]); + } + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3h Task 0 — no consumer yet: Task 3 (`dawarich-sync.service.ts`'s +// `listTripsToSync`, DSY2 — `date('now','-400 days')` / `date('now','+1 +// day')`) and Task 4 (`route-usage.service.ts`'s `purgeExpired`, RU4 — +// `date('now', ?)` bound with the modifier `-${RETENTION_DAYS} days`, and +// RETENTION_DAYS (`route-usage.service.ts:13`) is a compile-time module +// constant (`400`), not a runtime value — so RU4's "bound parameter" and +// DSY2's "literal" are the SAME shape at the call site once the modifier is +// known, confirmed directly against both files' actual source rather than +// assumed from the legacy SQL's own binding style) wire `nowDateOffset` in +// when each converts its own statement. +// +// Plan 3f's own four `date('now')` sites (atlas.service.ts AT39/AT42/AT44/ +// AT45 — 3g's inventory's own open question, inherited from 3f and never +// resolved until now) do NOT go through a dialect helper at all: `todayUtc()` +// (a `@trek/shared` JS function) resolves the calendar date once in the +// CALLING SERVICE (`atlas.service.ts:912`, `const trip = await +// this.trips.lastStartedTrip(userId, todayUtc())`) and the repository binds +// it as a plain parameter (`Trips.repository.ts#lastStartedTrip`: +// `.where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', +// today))`) — confirmed by reading the actual converted code at HEAD, not +// the plan document's description of it. That pattern covers a BARE +// `date('now')` (no offset) cleanly, and needs no new helper here. It does +// not extend to an OFFSET the way DSY2/RU4 need without every call site +// re-deriving day arithmetic in JS by hand, so this file instead grows the +// sibling `nowMinusDays`/`nowMinusHours` already establish for exactly this +// family (Plan 3c Task 0b/Plan 3f Task 0 R9): a signed day-count offset from +// "now", spelled into the fragment after integer validation, the same +// reasoning those two helpers give (MikroORM's raw fragments do not accept a +// placeholder inside a SQLite date/time function's modifier argument). +// `PlaceShadowPicksRepository.expireStale` (Plan 3c Task 1) is this shape's +// own closest working precedent — a retention-window purge built on +// `nowMinusDays` exactly the way `RouteUsageDailyRepository`'s own purge +// (RU4) will be built on `nowDateOffset` below. +// --------------------------------------------------------------------------- + +/** + * `date('now', 'N days')` — the SQLite clock's CALENDAR DATE (`date()`, + * not `datetime()` — `nowMinusDays` above is the wrong shape for this: it + * targets `datetime()`, and only ever subtracts), shifted by a signed whole + * day count. Unlike `nowMinusDays`, `days` may be POSITIVE (forward), + * NEGATIVE (backward) or zero — `dawarich-sync.service.ts`'s + * `listTripsToSync` (DSY2) needs both directions in the SAME statement + * (`date('now','-400 days')` for the trailing edge, `date('now','+1 day')` + * for the leading one). `days` is spelled directly into the fragment (not + * bound), the same reasoning `nowMinusDays`'s own docstring gives. + * + * Verified directly (not assumed) that SQLite's modifier keyword tolerates + * the singular/plural mismatch between the two legacy texts (`'+1 day'` + * singular, `'-400 days'` plural): `date('now','+1 day')` and + * `date('now','+1 days')` render the identical result against a live + * `better-sqlite3` connection, so this helper's always-plural spelling is + * behaviourally identical to both legacy statements, not just the plural one. + */ +export function nowDateOffset(platform: Platform, days: number): RawQueryFragment { + if (!Number.isInteger(days)) { + throw new Error(`sql-functions: nowDateOffset needs an integer day count, got ${days}`); + } + if (platform instanceof SqlitePlatform) { + const sign = days < 0 ? '-' : '+'; + return raw(`date('now', '${sign}${Math.abs(days)} days')`); + } + return unsupported(platform); +} + +/** + * `datetime('now', '+N seconds')` — the seconds-granularity, ADDING sibling + * of `nowMinusDays`/`nowMinusHours` above: the shape `doc-sync.service.ts`'s + * `upsertItem` (DS23, inside an `ON CONFLICT DO UPDATE`'s `CASE WHEN`; DS26, + * inside the sibling `INSERT ... VALUES`) and `recordLinkFailure` (a third, + * structurally identical call site found re-reading the file at HEAD — + * `next_attempt_at = datetime('now', '+' || ? || ' seconds')`, + * `doc-sync.service.ts:967`, not individually numbered in the plan's own + * inventory) all need: a caller-computed backoff window (`backoffSeconds( + * ...)`, a genuine RUNTIME value, never a source-code constant the way + * `nowMinusDays`'s callers' day counts are) added to the database clock. + * `seconds` is validated non-negative-integer and spelled directly into the + * fragment — the same reasoning (and the same MikroORM raw-fragment + * placeholder limitation) `nowMinusDays`'s own docstring gives; every known + * call site's `seconds` argument is already a JS-computed `number`, never + * user input, the same trust boundary this file relies on elsewhere + * (`dateAdd`'s day count, `substring`'s start/length). + */ +export function nowPlusSeconds(platform: Platform, seconds: number): RawQueryFragment { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSeconds needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '+${seconds} seconds')`); + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link nowPlusSeconds}: DS23/DS24's + * `ON CONFLICT ... DO UPDATE` / `INSERT ... VALUES` (R3) is a hand-typed + * Kysely statement — MikroORM's `em.upsert` cannot express a + * partial-unique-index conflict target (R3's own ruling) — so the + * `next_attempt_at` computation inside its `CASE WHEN` needs a Kysely + * `Expression`, not a MikroORM `RawQueryFragment` (SQLF-048: a + * `RawQueryFragment` throws when handed into a Kysely statement). Same + * validation, same always-non-negative-integer trust boundary as the + * MikroORM form above. + */ +export function nowPlusSecondsKysely( + platform: Platform, + eb: ExpressionBuilder, + seconds: number, +): ExpressionWrapper { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSecondsKysely needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) { + return eb.fn('datetime', [eb.val('now'), eb.val(`+${seconds} seconds`)]); + } + 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); +} + +// --------------------------------------------------------------------------- +// `UsersRepository.setImmichSettings` (IM4, #2475): the self-signed switch is +// trusted for ONE server, so whether a null incoming value may keep the stored +// choice depends on whether the URL written in the same SET list is the URL +// already stored. Like `foundAgainState` above, the WHEN condition reads the +// ROW'S OWN CURRENT column, which is not knowable in JS before the UPDATE runs +// — a read-then-write would be two statements where the legacy SQL had one. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN IS ? THEN COALESCE(?, ) ELSE COALESCE(?, 0) END` — + * {@link coalesceOverride}'s "new value wins unless it is null" shape, but the + * stored column is only a valid fallback WHILE the key column still holds + * `keyValue` (the value the same UPDATE writes to it). Once the key changes, + * a null `value` falls back to the column's default `0` instead of a choice + * made for a different key. SET expressions see the row as it was before the + * UPDATE, so ` IS ?` compares the STORED key with the new one even + * though the same statement overwrites it. `value` is bound twice on purpose: + * the fragment has two value slots and `raw()` binds positionally. + */ +export function coalesceOverrideWhileSame(platform: Platform, value: number | null, ref: string, keyRef: string, keyValue: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw(`CASE WHEN ${column(keyRef)} IS ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`, [keyValue, value, value]); + } + return unsupported(platform); +} + diff --git a/server/src/db/document-provider-seed.ts b/server/src/db/document-provider-seed.ts index 16e2467a8..6d51e6f50 100644 --- a/server/src/db/document-provider-seed.ts +++ b/server/src/db/document-provider-seed.ts @@ -18,7 +18,7 @@ import type Database from 'better-sqlite3'; * scanning the addon shelf should find the product they actually run. */ -interface ProviderRow { +export interface ProviderRow { id: string; name: string; description: string; @@ -26,7 +26,7 @@ interface ProviderRow { sort_order: number; } -interface ProviderFieldRow { +export interface ProviderFieldRow { provider_id: string; field_key: string; label: string; @@ -38,7 +38,7 @@ interface ProviderFieldRow { sort_order: number; } -const PROVIDERS: ProviderRow[] = [ +export const DOCUMENT_PROVIDERS: ProviderRow[] = [ { id: 'paperless', name: 'Paperless-ngx', @@ -82,56 +82,286 @@ const PROVIDERS: ProviderRow[] = [ * instance with a self-signed certificate is common enough to need the switch * and not common enough to justify shipping it on. */ -const FIELDS: ProviderFieldRow[] = [ +export const DOCUMENT_PROVIDER_FIELDS: ProviderFieldRow[] = [ // Paperless-ngx: token auth, tag scope. - { provider_id: 'paperless', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'paperless', field_key: 'api_token', label: 'providerApiToken', input_type: 'password', placeholder: 'API token', hint: 'hintPaperlessToken', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'paperless', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 2 }, + { + provider_id: 'paperless', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://paperless.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'paperless', + field_key: 'api_token', + label: 'providerApiToken', + input_type: 'password', + placeholder: 'API token', + hint: 'hintPaperlessToken', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'paperless', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 2, + }, // Papra: bearer key, and an organisation the key must already belong to. - { provider_id: 'papra', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://papra.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'papra', field_key: 'api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'ppapi_…', hint: 'hintPapraKey', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'papra', field_key: 'organization_id', label: 'providerOrganization', input_type: 'text', placeholder: 'org_…', hint: 'hintPapraOrg', required: 1, secret: 0, sort_order: 2 }, - { provider_id: 'papra', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'papra', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://papra.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'papra', + field_key: 'api_key', + label: 'providerApiKey', + input_type: 'password', + placeholder: 'ppapi_…', + hint: 'hintPapraKey', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'papra', + field_key: 'organization_id', + label: 'providerOrganization', + input_type: 'text', + placeholder: 'org_…', + hint: 'hintPapraOrg', + required: 1, + secret: 0, + sort_order: 2, + }, + { + provider_id: 'papra', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Nextcloud: an app password, never the account password. The latter breaks // the moment the account turns on 2FA or logs in through OIDC. - { provider_id: 'nextcloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'nextcloud', field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'nextcloud', field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'nextcloud', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 3 }, - { provider_id: 'nextcloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 4 }, + { + provider_id: 'nextcloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://cloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'nextcloud', + field_key: 'login_name', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintNextcloudLogin', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'nextcloud', + field_key: 'app_password', + label: 'providerAppPassword', + input_type: 'password', + placeholder: 'app password', + hint: 'hintNextcloudAppPassword', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'nextcloud', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/TREK', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 3, + }, + { + provider_id: 'nextcloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 4, + }, // OpenCloud: auth-app token, spaces instead of folders. - { provider_id: 'opencloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://opencloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'opencloud', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: null, required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'opencloud', field_key: 'app_token', label: 'providerAppToken', input_type: 'password', placeholder: 'app token', hint: 'hintOpenCloudToken', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'opencloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'opencloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://opencloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'opencloud', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: null, + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'opencloud', + field_key: 'app_token', + label: 'providerAppToken', + input_type: 'password', + placeholder: 'app token', + hint: 'hintOpenCloudToken', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'opencloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Synology: DSM has no app tokens at all, so this is a real account. The OTP // is used once to obtain a device token, which is what gets stored. - { provider_id: 'synologydrive', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'synologydrive', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintSynologyUser', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'synologydrive', field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'synologydrive', field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: 0, secret: 1, sort_order: 3 }, - { provider_id: 'synologydrive', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/trek', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 4 }, - { provider_id: 'synologydrive', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 5 }, + { + provider_id: 'synologydrive', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://nas.example.com:5001', + hint: 'hintSynologyUrl', + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'synologydrive', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintSynologyUser', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'synologydrive', + field_key: 'password', + label: 'providerPassword', + input_type: 'password', + placeholder: 'password', + hint: null, + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'synologydrive', + field_key: 'otp_code', + label: 'providerOTP', + input_type: 'text', + placeholder: '123456', + hint: 'hintSynologyOtp', + required: 0, + secret: 1, + sort_order: 3, + }, + { + provider_id: 'synologydrive', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/trek', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 4, + }, + { + provider_id: 'synologydrive', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 5, + }, ]; export function seedDocumentProviders(db: Database.Database): void { const insertProvider = db.prepare( 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', ); - for (const p of PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); + for (const p of DOCUMENT_PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); const insertField = db.prepare( `INSERT OR IGNORE INTO document_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - for (const f of FIELDS) { - insertField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order); + for (const f of DOCUMENT_PROVIDER_FIELDS) { + insertField.run( + f.provider_id, + f.field_key, + f.label, + f.input_type, + f.placeholder, + f.hint, + f.required, + f.secret, + f.sort_order, + ); } } -export const DOCUMENT_PROVIDER_SEED_IDS = PROVIDERS.map((p) => p.id); +export const DOCUMENT_PROVIDER_SEED_IDS = DOCUMENT_PROVIDERS.map((p) => p.id); diff --git a/server/src/db/entities/Addons.entity.ts b/server/src/db/entities/Addons.entity.ts new file mode 100644 index 000000000..06d317782 --- /dev/null +++ b/server/src/db/entities/Addons.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { AddonsRepository } from '../repositories/Addons.repository'; + +export interface AddonConfig { + [key: string]: unknown; +} + +export class Addons { + [EntityRepositoryType]?: AddonsRepository; + id?: string | null; + name!: string; + description?: string | null; + type: string & Opt = 'global'; + icon?: string | null = 'Puzzle'; + enabled?: boolean | null = false; + config?: AddonConfig | null; + sort_order?: number | null = 0; +} + +export const AddonsSchema = defineEntity({ + class: Addons, + repository: () => AddonsRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + type: p.text().default('global'), + icon: p.text().nullable(), + enabled: p.boolean().nullable(), + config: p.json().nullable().default('{}'), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/AppSettings.entity.ts b/server/src/db/entities/AppSettings.entity.ts new file mode 100644 index 000000000..cc57c6318 --- /dev/null +++ b/server/src/db/entities/AppSettings.entity.ts @@ -0,0 +1,18 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { AppSettingsRepository } from '../repositories/AppSettings.repository'; + +export class AppSettings { + [EntityRepositoryType]?: AppSettingsRepository; + [PrimaryKeyProp]?: 'key'; + key?: string | null; + value?: string | null; +} + +export const AppSettingsSchema = defineEntity({ + class: AppSettings, + repository: () => AppSettingsRepository, + properties: { + key: p.text().primary().nullable(), + value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/AssignmentParticipants.entity.ts b/server/src/db/entities/AssignmentParticipants.entity.ts new file mode 100644 index 000000000..d1b4dd36a --- /dev/null +++ b/server/src/db/entities/AssignmentParticipants.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { AssignmentParticipantsRepository } from '../repositories/AssignmentParticipants.repository'; +import { DayAssignments } from './DayAssignments.entity'; +import { Users } from './Users.entity'; + +export class AssignmentParticipants { + [EntityRepositoryType]?: AssignmentParticipantsRepository; + id!: number & Opt; + assignment!: Ref; + assignment_id!: number; + user!: Ref; + user_id!: number; +} + +export const AssignmentParticipantsSchema = defineEntity({ + class: AssignmentParticipants, + repository: () => AssignmentParticipantsRepository, + uniques: [{ properties: ['assignment', 'user'] }], + properties: { + id: p.integer().primary(), + assignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').hidden().index('idx_assignment_participants_assignment'), + assignment_id: p.integer().persist(false).index('idx_assignment_participants_assignment'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/AuditLog.entity.ts b/server/src/db/entities/AuditLog.entity.ts new file mode 100644 index 000000000..696b57a0a --- /dev/null +++ b/server/src/db/entities/AuditLog.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { AuditLogRepository } from '../repositories/AuditLog.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class AuditLog { + [EntityRepositoryType]?: AuditLogRepository; + id!: number & Opt; + created_at?: string | null; + user?: Ref | null; + user_id?: number | null; + action!: string; + resource?: string | null; + details?: string | null; + ip?: string | null; +} + +export const AuditLogSchema = defineEntity({ + class: AuditLog, + repository: () => AuditLogRepository, + properties: { + id: p.integer().primary(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_audit_log_created'), + user: () => p.manyToOne(Users).ref().nullable().hidden(), + user_id: p.integer().nullable().persist(false), + action: p.text(), + resource: p.text().nullable(), + details: p.text().nullable(), + ip: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/BucketList.entity.ts b/server/src/db/entities/BucketList.entity.ts new file mode 100644 index 000000000..3fa087617 --- /dev/null +++ b/server/src/db/entities/BucketList.entity.ts @@ -0,0 +1,46 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BucketListRepository } from '../repositories/BucketList.repository'; +import { DbTimestampType } from '../types'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { Users } from './Users.entity'; + +export class BucketList { + [EntityRepositoryType]?: BucketListRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + lat?: number | null; + lng?: number | null; + country_code?: string | null; + notes?: string | null; + created_at?: string | null; + target_date?: string | null; + visited_at?: string | null; + visited_source?: string | null; + region_code?: string | null; + dawarich_visit_suggestions_collection = new Collection(this); + dawarich_visit_suggestions_collection1 = new Collection(this); +} + +export const BucketListSchema = defineEntity({ + class: BucketList, + repository: () => BucketListRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + lat: p.double().nullable(), + lng: p.double().nullable(), + country_code: p.text().nullable(), + notes: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + target_date: p.text().nullable().defaultRaw(`NULL`), + visited_at: p.text().nullable(), + visited_source: p.text().nullable(), + region_code: p.text().nullable(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedBucketListItem').hidden(), + dawarich_visit_suggestions_collection1: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('matchedBucketListItem').hidden(), + }, +}); diff --git a/server/src/db/entities/BudgetCategoryOrder.entity.ts b/server/src/db/entities/BudgetCategoryOrder.entity.ts new file mode 100644 index 000000000..3fdbb27d1 --- /dev/null +++ b/server/src/db/entities/BudgetCategoryOrder.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetCategoryOrderRepository } from '../repositories/BudgetCategoryOrder.repository'; +import { Trips } from './Trips.entity'; + +export class BudgetCategoryOrder { + [EntityRepositoryType]?: BudgetCategoryOrderRepository; + [PrimaryKeyProp]?: ['trip', 'category']; + trip!: Ref; + trip_id!: number; + category!: string; + sort_order: number & Opt = 0; +} + +export const BudgetCategoryOrderSchema = defineEntity({ + class: BudgetCategoryOrder, + repository: () => BudgetCategoryOrderRepository, + uniques: [{ properties: ['trip', 'category'] }], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category: p.text().primary(), + sort_order: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/BudgetItemMembers.entity.ts b/server/src/db/entities/BudgetItemMembers.entity.ts new file mode 100644 index 000000000..507a1cbc2 --- /dev/null +++ b/server/src/db/entities/BudgetItemMembers.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemMembersRepository } from '../repositories/BudgetItemMembers.repository'; +import { BudgetItems } from './BudgetItems.entity'; +import { Users } from './Users.entity'; + +export class BudgetItemMembers { + [EntityRepositoryType]?: BudgetItemMembersRepository; + id!: number & Opt; + budgetItem!: Ref; + budget_item_id!: number; + user!: Ref; + user_id!: number; + paid: number & Opt = 0; + amount?: number | null; +} + +export const BudgetItemMembersSchema = defineEntity({ + class: BudgetItemMembers, + repository: () => BudgetItemMembersRepository, + uniques: [{ properties: ['budgetItem', 'user'] }], + properties: { + id: p.integer().primary(), + budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_members_item'), + budget_item_id: p.integer().persist(false).index('idx_budget_item_members_item'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_budget_item_members_user'), + user_id: p.integer().persist(false).index('idx_budget_item_members_user'), + paid: p.integer().default(0), + amount: p.double().nullable(), + }, +}); diff --git a/server/src/db/entities/BudgetItemPayers.entity.ts b/server/src/db/entities/BudgetItemPayers.entity.ts new file mode 100644 index 000000000..5897e81f4 --- /dev/null +++ b/server/src/db/entities/BudgetItemPayers.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemPayersRepository } from '../repositories/BudgetItemPayers.repository'; +import { BudgetItems } from './BudgetItems.entity'; +import { Users } from './Users.entity'; + +export class BudgetItemPayers { + [EntityRepositoryType]?: BudgetItemPayersRepository; + id!: number & Opt; + budgetItem!: Ref; + budget_item_id!: number; + user!: Ref; + user_id!: number; + amount!: number & Opt; +} + +export const BudgetItemPayersSchema = defineEntity({ + class: BudgetItemPayers, + repository: () => BudgetItemPayersRepository, + uniques: [{ properties: ['budgetItem', 'user'] }], + properties: { + id: p.integer().primary(), + budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_payers_item'), + budget_item_id: p.integer().persist(false).index('idx_budget_item_payers_item'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + amount: p.double().defaultRaw(`0`), + }, +}); diff --git a/server/src/db/entities/BudgetItems.entity.ts b/server/src/db/entities/BudgetItems.entity.ts new file mode 100644 index 000000000..ad9b2bcc6 --- /dev/null +++ b/server/src/db/entities/BudgetItems.entity.ts @@ -0,0 +1,69 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemsRepository } from '../repositories/BudgetItems.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItemMembers } from './BudgetItemMembers.entity'; +import { BudgetItemPayers } from './BudgetItemPayers.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class BudgetItems { + [EntityRepositoryType]?: BudgetItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category: string & Opt = 'Other'; + name!: string; + total_price!: number & Opt; + persons?: number | null; + days?: number | null; + note?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + paidByUser?: Ref | null; + paid_by_user_id?: number | null; + expense_date?: string | null; + reservation?: Ref | null; + reservation_id?: number | null; + currency?: string | null; + exchange_rate!: number & Opt; + ticket_json?: string | null; + place?: Ref | null; + place_id?: number | null; + budget_item_members_collection = new Collection(this); + budget_item_payers_collection = new Collection(this); + file_links_collection = new Collection(this); +} + +export const BudgetItemsSchema = defineEntity({ + class: BudgetItems, + repository: () => BudgetItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_budget_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_budget_items_trip_id'), + category: p.text().default('Other'), + name: p.text(), + total_price: p.double().defaultRaw(`0`), + persons: p.integer().nullable().defaultRaw(`NULL`), + days: p.integer().nullable().defaultRaw(`NULL`), + note: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + paidByUser: () => p.manyToOne(Users).ref().deleteRule('no action').nullable().hidden(), + paid_by_user_id: p.integer().nullable().persist(false), + expense_date: p.text().nullable().defaultRaw(`NULL`), + reservation: () => p.manyToOne(Reservations).ref().nullable().hidden().defaultRaw(`NULL`), + reservation_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + currency: p.text().nullable(), + exchange_rate: p.double().defaultRaw(`1`), + ticket_json: p.text().nullable(), + place: () => p.manyToOne(Places).ref().nullable().hidden().defaultRaw(`NULL`), + place_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + budget_item_members_collection: () => p.oneToMany(BudgetItemMembers).mappedBy('budgetItem').hidden(), + budget_item_payers_collection: () => p.oneToMany(BudgetItemPayers).mappedBy('budgetItem').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('budgetItem').hidden(), + }, +}); diff --git a/server/src/db/entities/BudgetSettlements.entity.ts b/server/src/db/entities/BudgetSettlements.entity.ts new file mode 100644 index 000000000..c0543b20a --- /dev/null +++ b/server/src/db/entities/BudgetSettlements.entity.ts @@ -0,0 +1,46 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetSettlementsRepository } from '../repositories/BudgetSettlements.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class BudgetSettlements { + [EntityRepositoryType]?: BudgetSettlementsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + fromUser!: Ref; + from_user_id!: number; + toUser!: Ref; + to_user_id!: number; + amount!: number & Opt; + created_at?: string | null; + createdByUser?: Ref | null; + created_by_user_id?: number | null; + currency?: string | null; + exchange_rate!: number & Opt; + settled_at?: string | null; + note?: string | null; +} + +export const BudgetSettlementsSchema = defineEntity({ + class: BudgetSettlements, + repository: () => BudgetSettlementsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_budget_settlements_trip'), + trip_id: p.integer().persist(false).index('idx_budget_settlements_trip'), + fromUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + from_user_id: p.integer().persist(false), + toUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + to_user_id: p.integer().persist(false), + amount: p.double().defaultRaw(`0`), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByUser: () => p.manyToOne(Users).ref().deleteRule('no action').nullable().hidden(), + created_by_user_id: p.integer().nullable().persist(false), + currency: p.text().nullable(), + exchange_rate: p.double().defaultRaw(`1`), + settled_at: p.text().nullable(), + note: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Categories.entity.ts b/server/src/db/entities/Categories.entity.ts new file mode 100644 index 000000000..f087532bf --- /dev/null +++ b/server/src/db/entities/Categories.entity.ts @@ -0,0 +1,35 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CategoriesRepository } from '../repositories/Categories.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class Categories { + [EntityRepositoryType]?: CategoriesRepository; + id!: number & Opt; + name!: string; + color?: string | null = '#6366f1'; + icon?: string | null = '📍'; + user?: Ref | null; + user_id?: number | null; + created_at?: string | null; + collection_places_collection = new Collection(this); + places_collection = new Collection(this); +} + +export const CategoriesSchema = defineEntity({ + class: Categories, + repository: () => CategoriesRepository, + properties: { + id: p.integer().primary(), + name: p.text(), + color: p.text().nullable(), + icon: p.text().nullable(), + user: () => p.manyToOne(Users).ref().nullable().hidden(), + user_id: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('category').hidden(), + places_collection: () => p.oneToMany(Places).mappedBy('category').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabLinks.entity.ts b/server/src/db/entities/CollabLinks.entity.ts new file mode 100644 index 000000000..9351f216b --- /dev/null +++ b/server/src/db/entities/CollabLinks.entity.ts @@ -0,0 +1,36 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabLinksRepository } from '../repositories/CollabLinks.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabLinks { + [EntityRepositoryType]?: CollabLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + title!: string; + url!: string; + pinned?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; +} + +export const CollabLinksSchema = defineEntity({ + class: CollabLinks, + repository: () => CollabLinksRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_links_trip'), + trip_id: p.integer().persist(false).index('idx_collab_links_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + title: p.text(), + url: p.text(), + pinned: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabMessageReactions.entity.ts b/server/src/db/entities/CollabMessageReactions.entity.ts new file mode 100644 index 000000000..03a08475f --- /dev/null +++ b/server/src/db/entities/CollabMessageReactions.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabMessageReactionsRepository } from '../repositories/CollabMessageReactions.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessages } from './CollabMessages.entity'; +import { Users } from './Users.entity'; + +export class CollabMessageReactions { + [EntityRepositoryType]?: CollabMessageReactionsRepository; + id!: number & Opt; + message!: Ref; + message_id!: number; + user!: Ref; + user_id!: number; + emoji!: string; + created_at?: string | null; +} + +export const CollabMessageReactionsSchema = defineEntity({ + class: CollabMessageReactions, + repository: () => CollabMessageReactionsRepository, + uniques: [{ properties: ['message', 'user', 'emoji'] }], + properties: { + id: p.integer().primary(), + message: () => p.manyToOne(CollabMessages).ref().deleteRule('cascade').hidden().index('idx_collab_reactions_msg'), + message_id: p.integer().persist(false).index('idx_collab_reactions_msg'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + emoji: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabMessages.entity.ts b/server/src/db/entities/CollabMessages.entity.ts new file mode 100644 index 000000000..9b6865bd6 --- /dev/null +++ b/server/src/db/entities/CollabMessages.entity.ts @@ -0,0 +1,44 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabMessagesRepository } from '../repositories/CollabMessages.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessageReactions } from './CollabMessageReactions.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabMessages { + [EntityRepositoryType]?: CollabMessagesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + text!: string; + reply_to?: number | null; + created_at?: string | null; + deleted?: number | null = 0; + replyToRef?: Ref | null; + collab_message_reactions_collection = new Collection(this); + collab_messages_collection = new Collection(this); + trip_files_collection = new Collection(this); +} + +export const CollabMessagesSchema = defineEntity({ + class: CollabMessages, + repository: () => CollabMessagesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_messages_trip'), + trip_id: p.integer().persist(false).index('idx_collab_messages_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + text: p.text(), + reply_to: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + deleted: p.integer().nullable(), + replyToRef: () => p.manyToOne(CollabMessages).ref().joinColumn('reply_to').nullable().hidden(), + collab_message_reactions_collection: () => p.oneToMany(CollabMessageReactions).mappedBy('message').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('replyToRef').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('message').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabNotes.entity.ts b/server/src/db/entities/CollabNotes.entity.ts new file mode 100644 index 000000000..4784161f7 --- /dev/null +++ b/server/src/db/entities/CollabNotes.entity.ts @@ -0,0 +1,45 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabNotesRepository } from '../repositories/CollabNotes.repository'; +import { DbTimestampType } from '../types'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabNotes { + [EntityRepositoryType]?: CollabNotesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + category?: string | null = 'General'; + title!: string; + content?: string | null; + color?: string | null = '#6366f1'; + pinned?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + website?: string | null; + trip_files_collection = new Collection(this); +} + +export const CollabNotesSchema = defineEntity({ + class: CollabNotes, + repository: () => CollabNotesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_notes_trip'), + trip_id: p.integer().persist(false).index('idx_collab_notes_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + category: p.text().nullable(), + title: p.text(), + content: p.text().nullable(), + color: p.text().nullable(), + pinned: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + website: p.text().nullable(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('note').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabPollVotes.entity.ts b/server/src/db/entities/CollabPollVotes.entity.ts new file mode 100644 index 000000000..45f877865 --- /dev/null +++ b/server/src/db/entities/CollabPollVotes.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabPollVotesRepository } from '../repositories/CollabPollVotes.repository'; +import { DbTimestampType } from '../types'; +import { CollabPolls } from './CollabPolls.entity'; +import { Users } from './Users.entity'; + +export class CollabPollVotes { + [EntityRepositoryType]?: CollabPollVotesRepository; + id!: number & Opt; + poll!: Ref; + poll_id!: number; + user!: Ref; + user_id!: number; + option_index!: number; + created_at?: string | null; +} + +export const CollabPollVotesSchema = defineEntity({ + class: CollabPollVotes, + repository: () => CollabPollVotesRepository, + uniques: [{ properties: ['poll', 'user', 'option_index'] }], + properties: { + id: p.integer().primary(), + poll: () => p.manyToOne(CollabPolls).ref().deleteRule('cascade').hidden(), + poll_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + option_index: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabPolls.entity.ts b/server/src/db/entities/CollabPolls.entity.ts new file mode 100644 index 000000000..080d40195 --- /dev/null +++ b/server/src/db/entities/CollabPolls.entity.ts @@ -0,0 +1,41 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabPollsRepository } from '../repositories/CollabPolls.repository'; +import { DbTimestampType } from '../types'; +import { CollabPollVotes } from './CollabPollVotes.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabPolls { + [EntityRepositoryType]?: CollabPollsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + question!: string; + options!: string; + multiple?: number | null = 0; + closed?: number | null = 0; + deadline?: string | null; + created_at?: string | null; + collab_poll_votes_collection = new Collection(this); +} + +export const CollabPollsSchema = defineEntity({ + class: CollabPolls, + repository: () => CollabPollsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_polls_trip'), + trip_id: p.integer().persist(false).index('idx_collab_polls_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + question: p.text(), + options: p.text(), + multiple: p.integer().nullable(), + closed: p.integer().nullable(), + deadline: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collab_poll_votes_collection: () => p.oneToMany(CollabPollVotes).mappedBy('poll').hidden(), + }, +}); diff --git a/server/src/db/entities/CollectionLabels.entity.ts b/server/src/db/entities/CollectionLabels.entity.ts new file mode 100644 index 000000000..fd2b4ebcc --- /dev/null +++ b/server/src/db/entities/CollectionLabels.entity.ts @@ -0,0 +1,32 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionLabelsRepository } from '../repositories/CollectionLabels.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Collections } from './Collections.entity'; + +export class CollectionLabels { + [EntityRepositoryType]?: CollectionLabelsRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + name!: string; + color?: string | null = '#6366f1'; + sort_order?: number | null = 0; + created_at?: string | null; + collection_place_labels_inverse = new Collection(this); +} + +export const CollectionLabelsSchema = defineEntity({ + class: CollectionLabels, + repository: () => CollectionLabelsRepository, + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_labels_collection'), + collection_id: p.integer().persist(false).index('idx_collection_labels_collection'), + name: p.text(), + color: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_place_labels_inverse: () => p.manyToMany(CollectionPlaces).mappedBy('collection_place_labels').hidden(), + }, +}); diff --git a/server/src/db/entities/CollectionMembers.entity.ts b/server/src/db/entities/CollectionMembers.entity.ts new file mode 100644 index 000000000..c69e4fb01 --- /dev/null +++ b/server/src/db/entities/CollectionMembers.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionMembersRepository } from '../repositories/CollectionMembers.repository'; +import { DbTimestampType } from '../types'; +import { Collections } from './Collections.entity'; +import { Users } from './Users.entity'; + +export class CollectionMembers { + [EntityRepositoryType]?: CollectionMembersRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + user!: Ref; + user_id!: number; + status: string & Opt = 'pending'; + role: string & Opt = 'editor'; + created_at?: string | null; +} + +export const CollectionMembersSchema = defineEntity({ + class: CollectionMembers, + repository: () => CollectionMembersRepository, + uniques: [{ properties: ['collection', 'user'] }], + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden(), + collection_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_collection_members_user'), + user_id: p.integer().persist(false).index('idx_collection_members_user'), + status: p.text().default('pending'), + role: p.text().default('editor'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollectionPlaceRatings.entity.ts b/server/src/db/entities/CollectionPlaceRatings.entity.ts new file mode 100644 index 000000000..0ebef9482 --- /dev/null +++ b/server/src/db/entities/CollectionPlaceRatings.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionPlaceRatingsRepository } from '../repositories/CollectionPlaceRatings.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Users } from './Users.entity'; + +export class CollectionPlaceRatings { + [EntityRepositoryType]?: CollectionPlaceRatingsRepository; + id!: number & Opt; + collectionPlace!: Ref; + collection_place_id!: number; + user!: Ref; + user_id!: number; + rating!: number; + created_at?: string | null; +} + +export const CollectionPlaceRatingsSchema = defineEntity({ + class: CollectionPlaceRatings, + repository: () => CollectionPlaceRatingsRepository, + uniques: [{ properties: ['collectionPlace', 'user'] }], + properties: { + id: p.integer().primary(), + collectionPlace: () => p.manyToOne(CollectionPlaces).ref().deleteRule('cascade').hidden().index('idx_collection_place_ratings_place'), + collection_place_id: p.integer().persist(false).index('idx_collection_place_ratings_place'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + rating: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollectionPlaces.entity.ts b/server/src/db/entities/CollectionPlaces.entity.ts new file mode 100644 index 000000000..2d76ca8ee --- /dev/null +++ b/server/src/db/entities/CollectionPlaces.entity.ts @@ -0,0 +1,86 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionPlacesRepository } from '../repositories/CollectionPlaces.repository'; +import { DbTimestampType } from '../types'; +import { Categories } from './Categories.entity'; +import { CollectionLabels } from './CollectionLabels.entity'; +import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity'; +import { Collections } from './Collections.entity'; +import { Tags } from './Tags.entity'; +import { Users } from './Users.entity'; + +export class CollectionPlaces { + [EntityRepositoryType]?: CollectionPlacesRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + owner!: Ref; + owner_id!: number; + saved_by?: number | null; + name!: string; + description?: string | null; + lat?: number | null; + lng?: number | null; + address?: string | null; + category?: Ref | 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; + osm_id?: string | null; + website?: string | null; + phone?: string | null; + status: string & Opt = 'idea'; + source_trip_id?: number | null; + source_place_id?: number | null; + links?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + savedByRef?: Ref | null; + collection_place_labels = new Collection(this); + collection_place_tags = new Collection(this); + collection_place_ratings_collection = new Collection(this); +} + +export const CollectionPlacesSchema = defineEntity({ + class: CollectionPlaces, + repository: () => CollectionPlacesRepository, + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_places_collection'), + collection_id: p.integer().persist(false).index('idx_collection_places_collection'), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + saved_by: p.integer().nullable().persist(false), + name: p.text(), + description: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + address: p.text().nullable(), + category: () => p.manyToOne(Categories).ref().nullable().hidden(), + category_id: p.integer().nullable().persist(false), + price: p.double().nullable(), + currency: p.text().nullable(), + notes: p.text().nullable(), + image_url: p.text().nullable(), + google_place_id: p.text().nullable(), + google_ftid: p.text().nullable(), + osm_id: p.text().nullable(), + website: p.text().nullable(), + phone: p.text().nullable(), + status: p.text().default('idea'), + source_trip_id: p.integer().nullable(), + source_place_id: p.integer().nullable(), + links: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + savedByRef: () => p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden(), + collection_place_labels: () => p.manyToMany(CollectionLabels).pivotTable('collection_place_labels').joinColumn('collection_place_id').inverseJoinColumn('label_id').hidden(), + collection_place_tags: () => p.manyToMany(Tags).pivotTable('collection_place_tags').joinColumn('collection_place_id').inverseJoinColumn('tag_id').hidden(), + collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('collectionPlace').hidden(), + }, +}); diff --git a/server/src/db/entities/Collections.entity.ts b/server/src/db/entities/Collections.entity.ts new file mode 100644 index 000000000..b7718755f --- /dev/null +++ b/server/src/db/entities/Collections.entity.ts @@ -0,0 +1,48 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionsRepository } from '../repositories/Collections.repository'; +import { DbTimestampType } from '../types'; +import { CollectionLabels } from './CollectionLabels.entity'; +import { CollectionMembers } from './CollectionMembers.entity'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Users } from './Users.entity'; + +export class Collections { + [EntityRepositoryType]?: CollectionsRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + name!: string; + description?: string | null; + color?: string | null = '#6366f1'; + icon?: string | null = 'Bookmark'; + cover_image?: string | null; + links?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + collection_labels_collection = new Collection(this); + collection_members_collection = new Collection(this); + collection_places_collection = new Collection(this); +} + +export const CollectionsSchema = defineEntity({ + class: Collections, + repository: () => CollectionsRepository, + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + name: p.text(), + description: p.text().nullable(), + color: p.text().nullable(), + icon: p.text().nullable(), + cover_image: p.text().nullable(), + links: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_labels_collection: () => p.oneToMany(CollectionLabels).mappedBy('collection').hidden(), + collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('collection').hidden(), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('collection').hidden(), + }, +}); diff --git a/server/src/db/entities/DawarichConnections.entity.ts b/server/src/db/entities/DawarichConnections.entity.ts new file mode 100644 index 000000000..cad8fff72 --- /dev/null +++ b/server/src/db/entities/DawarichConnections.entity.ts @@ -0,0 +1,40 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DawarichConnectionsRepository } from '../repositories/DawarichConnections.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class DawarichConnections { + [EntityRepositoryType]?: DawarichConnectionsRepository; + [PrimaryKeyProp]?: 'user'; + user?: Ref | null; + user_id?: number | null; + url?: string | null; + api_key?: string | null; + allow_insecure_tls: number & Opt = 0; + sync_enabled: number & Opt = 1; + last_sync_at?: string | null; + last_sync_state: string & Opt = 'never'; + last_sync_error?: string | null; + capabilities?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export const DawarichConnectionsSchema = defineEntity({ + class: DawarichConnections, + repository: () => DawarichConnectionsRepository, + properties: { + user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + url: p.text().nullable(), + api_key: p.text().nullable(), + allow_insecure_tls: p.integer().default(0), + sync_enabled: p.integer().default(1), + last_sync_at: p.text().nullable(), + last_sync_state: p.text().default('never'), + last_sync_error: p.text().nullable(), + capabilities: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DawarichVisitSuggestions.entity.ts b/server/src/db/entities/DawarichVisitSuggestions.entity.ts new file mode 100644 index 000000000..07b6198ca --- /dev/null +++ b/server/src/db/entities/DawarichVisitSuggestions.entity.ts @@ -0,0 +1,86 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DawarichVisitSuggestionsRepository } from '../repositories/DawarichVisitSuggestions.repository'; +import { BucketList } from './BucketList.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class DawarichVisitSuggestions { + [EntityRepositoryType]?: DawarichVisitSuggestionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + source_visit_id!: string; + trip?: Ref | null; + trip_id?: number | null; + name!: string; + lat?: number | null; + lng?: number | null; + started_at!: string; + ended_at!: string; + duration_minutes: number & Opt = 0; + local_date!: string; + source_status: string & Opt = 'suggested'; + confidence?: number | null; + confidence_band?: string | null; + country_code?: string | null; + state: string & Opt = 'new'; + target?: string | null; + acceptedPlace?: Ref | null; + accepted_place_id?: number | null; + accepted_journal_entry_id?: number | null; + acceptedBucketListItem?: Ref | null; + accepted_bucket_list_item_id?: number | null; + matchedBucketListItem?: Ref | null; + matched_bucket_list_item_id?: number | null; + source_hash!: string; + accepted_hash?: string | null; + source_missing_at?: string | null; + first_seen_at!: string & Opt; + last_seen_at!: string & Opt; +} + +export const DawarichVisitSuggestionsSchema = defineEntity({ + class: DawarichVisitSuggestions, + repository: () => DawarichVisitSuggestionsRepository, + indexes: [ + { + name: 'idx_dawarich_suggestions_user_state', + properties: ['user_id', 'state'], + }, + ], + uniques: [{ properties: ['user', 'source_visit_id'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + source_visit_id: p.text(), + trip: () => p.manyToOne(Trips).ref().nullable().hidden().index('idx_dawarich_suggestions_trip'), + trip_id: p.integer().nullable().persist(false).index('idx_dawarich_suggestions_trip'), + name: p.text(), + lat: p.double().nullable(), + lng: p.double().nullable(), + started_at: p.text(), + ended_at: p.text(), + duration_minutes: p.integer().default(0), + local_date: p.text(), + source_status: p.text().default('suggested'), + confidence: p.double().nullable(), + confidence_band: p.text().nullable(), + country_code: p.text().nullable(), + state: p.text().default('new'), + target: p.text().nullable(), + acceptedPlace: () => p.manyToOne(Places).ref().nullable().hidden(), + accepted_place_id: p.integer().nullable().persist(false), + accepted_journal_entry_id: p.integer().nullable(), + acceptedBucketListItem: () => p.manyToOne(BucketList).ref().nullable().hidden(), + accepted_bucket_list_item_id: p.integer().nullable().persist(false), + matchedBucketListItem: () => p.manyToOne(BucketList).ref().nullable().hidden(), + matched_bucket_list_item_id: p.integer().nullable().persist(false), + source_hash: p.text(), + accepted_hash: p.text().nullable(), + source_missing_at: p.text().nullable(), + first_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DayAccommodations.entity.ts b/server/src/db/entities/DayAccommodations.entity.ts new file mode 100644 index 000000000..71a86ea1e --- /dev/null +++ b/server/src/db/entities/DayAccommodations.entity.ts @@ -0,0 +1,47 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayAccommodationsRepository } from '../repositories/DayAccommodations.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; + +export class DayAccommodations { + [EntityRepositoryType]?: DayAccommodationsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + place?: Ref | null; + place_id?: number | null; + startDay!: Ref; + start_day_id!: number; + endDay!: Ref; + end_day_id!: number; + check_in?: string | null; + check_in_end?: string | null; + check_out?: string | null; + confirmation?: string | null; + notes?: string | null; + created_at?: string | null; +} + +export const DayAccommodationsSchema = defineEntity({ + class: DayAccommodations, + repository: () => DayAccommodationsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_trip_id'), + trip_id: p.integer().persist(false).index('idx_day_accommodations_trip_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + startDay: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_start_day_id'), + start_day_id: p.integer().persist(false).index('idx_day_accommodations_start_day_id'), + endDay: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_end_day_id'), + end_day_id: p.integer().persist(false).index('idx_day_accommodations_end_day_id'), + check_in: p.text().nullable(), + check_in_end: p.text().nullable(), + check_out: p.text().nullable(), + confirmation: p.text().nullable(), + notes: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DayAssignments.entity.ts b/server/src/db/entities/DayAssignments.entity.ts new file mode 100644 index 000000000..140edd2d1 --- /dev/null +++ b/server/src/db/entities/DayAssignments.entity.ts @@ -0,0 +1,66 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayAssignmentsRepository } from '../repositories/DayAssignments.repository'; +import { DbTimestampType } from '../types'; +import { AssignmentParticipants } from './AssignmentParticipants.entity'; +import { Days } from './Days.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayBoundaries } from './RoadtripDayBoundaries.entity'; + +export class DayAssignments { + [EntityRepositoryType]?: DayAssignmentsRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + place!: Ref; + place_id!: number; + order_index?: number | null = 0; + notes?: string | null; + reservation_status?: string | null = 'none'; + reservation_notes?: string | null; + reservation_datetime?: string | null; + created_at?: string | null; + assignment_time?: string | null; + assignment_end_time?: string | null; + leg_transport_mode?: string | null; + incoming_leg_transport_mode?: string | null; + end_day: number & Opt = 0; + accommodation_id?: number | null; + route_excluded: number & Opt = 0; + assignment_participants_collection = new Collection(this); + file_links_collection = new Collection(this); + reservations_collection = new Collection(this); + roadtrip_day_boundaries_collection = new Collection(this); + roadtrip_day_boundaries_collection1 = new Collection(this); +} + +export const DayAssignmentsSchema = defineEntity({ + class: DayAssignments, + repository: () => DayAssignmentsRepository, + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_assignments_day_id'), + day_id: p.integer().persist(false).index('idx_day_assignments_day_id'), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_day_assignments_place_id'), + place_id: p.integer().persist(false).index('idx_day_assignments_place_id'), + order_index: p.integer().nullable(), + notes: p.text().nullable(), + reservation_status: p.text().nullable(), + reservation_notes: p.text().nullable(), + reservation_datetime: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + assignment_time: p.text().nullable(), + assignment_end_time: p.text().nullable(), + leg_transport_mode: p.text().nullable(), + incoming_leg_transport_mode: p.text().nullable(), + end_day: p.integer().default(0), + accommodation_id: p.integer().nullable().index('idx_day_assignments_accommodation_id'), + route_excluded: p.integer().default(0), + assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('assignment').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('assignment').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('assignment').hidden(), + roadtrip_day_boundaries_collection: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('fromAssignment').hidden(), + roadtrip_day_boundaries_collection1: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('toAssignment').hidden(), + }, +}); diff --git a/server/src/db/entities/DayNotes.entity.ts b/server/src/db/entities/DayNotes.entity.ts new file mode 100644 index 000000000..603ec3dff --- /dev/null +++ b/server/src/db/entities/DayNotes.entity.ts @@ -0,0 +1,38 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayNotesRepository } from '../repositories/DayNotes.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Trips } from './Trips.entity'; + +export class DayNotes { + [EntityRepositoryType]?: DayNotesRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + trip!: Ref; + trip_id!: number; + text!: string; + time?: string | null; + icon?: string | null = '📝'; + sort_order?: number | null; + created_at?: string | null; + color?: string | null; +} + +export const DayNotesSchema = defineEntity({ + class: DayNotes, + repository: () => DayNotesRepository, + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_notes_day_id'), + day_id: p.integer().persist(false).index('idx_day_notes_day_id'), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + text: p.text(), + time: p.text().nullable(), + icon: p.text().nullable(), + sort_order: p.double().nullable().defaultRaw(`0`), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + color: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Days.entity.ts b/server/src/db/entities/Days.entity.ts new file mode 100644 index 000000000..480e021f3 --- /dev/null +++ b/server/src/db/entities/Days.entity.ts @@ -0,0 +1,59 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DaysRepository } from '../repositories/Days.repository'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { DayNotes } from './DayNotes.entity'; +import { Photos } from './Photos.entity'; +import { ReservationDayPositions } from './ReservationDayPositions.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayTracks } from './RoadtripDayTracks.entity'; +import { RoadtripVias } from './RoadtripVias.entity'; +import { Trips } from './Trips.entity'; + +export class Days { + [EntityRepositoryType]?: DaysRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day_number!: number; + date?: string | null; + notes?: string | null; + title?: string | null; + default_transport_mode?: string | null; + day_accommodations_collection = new Collection(this); + day_accommodations_collection1 = new Collection(this); + day_assignments_collection = new Collection(this); + day_notes_collection = new Collection(this); + photos_collection = new Collection(this); + reservation_day_positions_collection = new Collection(this); + reservations_collection = new Collection(this); + reservations_collection1 = new Collection(this); + roadtrip_day_tracks: Ref | null = null; + roadtrip_vias_collection = new Collection(this); +} + +export const DaysSchema = defineEntity({ + class: Days, + repository: () => DaysRepository, + uniques: [{ properties: ['trip', 'day_number'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_days_trip_id'), + trip_id: p.integer().persist(false).index('idx_days_trip_id'), + day_number: p.integer(), + date: p.text().nullable(), + notes: p.text().nullable(), + title: p.text().nullable(), + default_transport_mode: p.text().nullable(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('startDay').hidden(), + day_accommodations_collection1: () => p.oneToMany(DayAccommodations).mappedBy('endDay').hidden(), + day_assignments_collection: () => p.oneToMany(DayAssignments).mappedBy('day').hidden(), + day_notes_collection: () => p.oneToMany(DayNotes).mappedBy('day').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('day').hidden(), + reservation_day_positions_collection: () => p.oneToMany(ReservationDayPositions).mappedBy('day').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('day').hidden(), + reservations_collection1: () => p.oneToMany(Reservations).mappedBy('endDay').hidden(), + roadtrip_day_tracks: () => p.oneToOne(RoadtripDayTracks).ref().mappedBy('day').hidden(), + roadtrip_vias_collection: () => p.oneToMany(RoadtripVias).mappedBy('day').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentConnections.entity.ts b/server/src/db/entities/DocumentConnections.entity.ts new file mode 100644 index 000000000..62e2b1f98 --- /dev/null +++ b/server/src/db/entities/DocumentConnections.entity.ts @@ -0,0 +1,55 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentConnectionsRepository } from '../repositories/DocumentConnections.repository'; +import { DbTimestampType } from '../types'; +import { DocumentProviders } from './DocumentProviders.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class DocumentConnections { + [EntityRepositoryType]?: DocumentConnectionsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + provider!: Ref; + provider_id!: string; + ownerUser!: Ref; + owner_user_id!: number; + base_url!: string; + secrets?: string | null; + settings: string & Opt = '{}'; + allow_insecure_tls: number & Opt = 0; + capabilities?: string | null; + last_probe_at?: string | null; + last_probe_state: string & Opt = 'never'; + last_probe_error?: string | null; + created_at?: string | null; + updated_at?: string | null; + trip_document_links_collection = new Collection(this); +} + +export const DocumentConnectionsSchema = defineEntity({ + class: DocumentConnections, + repository: () => DocumentConnectionsRepository, + uniques: [{ properties: ['trip', 'provider'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_document_connections_trip'), + trip_id: p.integer().persist(false).index('idx_document_connections_trip'), + provider: () => p.manyToOne(DocumentProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + ownerUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_document_connections_owner'), + owner_user_id: p.integer().persist(false).index('idx_document_connections_owner'), + base_url: p.text(), + secrets: p.text().nullable(), + settings: p.text().default('{}'), + allow_insecure_tls: p.integer().default(0), + capabilities: p.text().nullable(), + last_probe_at: p.text().nullable(), + last_probe_state: p.text().default('never'), + last_probe_error: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('connection').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentProviderFields.entity.ts b/server/src/db/entities/DocumentProviderFields.entity.ts new file mode 100644 index 000000000..a9a0b8512 --- /dev/null +++ b/server/src/db/entities/DocumentProviderFields.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentProviderFieldsRepository } from '../repositories/DocumentProviderFields.repository'; +import { DocumentProviders } from './DocumentProviders.entity'; + +export class DocumentProviderFields { + [EntityRepositoryType]?: DocumentProviderFieldsRepository; + id!: number & Opt; + provider!: Ref; + provider_id!: string; + field_key!: string; + label!: string; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required?: number | null = 0; + secret?: number | null = 0; + sort_order?: number | null = 0; +} + +export const DocumentProviderFieldsSchema = defineEntity({ + class: DocumentProviderFields, + repository: () => DocumentProviderFieldsRepository, + uniques: [{ properties: ['provider', 'field_key'] }], + properties: { + id: p.integer().primary(), + provider: () => p.manyToOne(DocumentProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + field_key: p.text(), + label: p.text(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().nullable(), + secret: p.integer().nullable(), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/DocumentProviders.entity.ts b/server/src/db/entities/DocumentProviders.entity.ts new file mode 100644 index 000000000..683a62280 --- /dev/null +++ b/server/src/db/entities/DocumentProviders.entity.ts @@ -0,0 +1,31 @@ +import { Collection, EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { DocumentProvidersRepository } from '../repositories/DocumentProviders.repository'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentProviderFields } from './DocumentProviderFields.entity'; + +export class DocumentProviders { + [EntityRepositoryType]?: DocumentProvidersRepository; + id?: string | null; + name!: string; + description?: string | null; + icon?: string | null = 'FileText'; + enabled?: number | null = 0; + sort_order?: number | null = 0; + document_connections_collection = new Collection(this); + document_provider_fields_collection = new Collection(this); +} + +export const DocumentProvidersSchema = defineEntity({ + class: DocumentProviders, + repository: () => DocumentProvidersRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + icon: p.text().nullable(), + enabled: p.integer().nullable(), + sort_order: p.integer().nullable(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('provider').hidden(), + document_provider_fields_collection: () => p.oneToMany(DocumentProviderFields).mappedBy('provider').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentSyncItems.entity.ts b/server/src/db/entities/DocumentSyncItems.entity.ts new file mode 100644 index 000000000..c5f6af221 --- /dev/null +++ b/server/src/db/entities/DocumentSyncItems.entity.ts @@ -0,0 +1,94 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentSyncItemsRepository } from '../repositories/DocumentSyncItems.repository'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class DocumentSyncItems { + [EntityRepositoryType]?: DocumentSyncItemsRepository; + id!: number & Opt; + link!: Ref; + link_id!: number; + trip!: Ref; + trip_id!: number; + file?: Ref | null; + file_id?: number | null; + trek_doc_uid!: string; + remote_id?: string | null; + remote_name?: string | null; + remote_version?: string | null; + remote_size?: number | null; + remote_modified_at?: string | null; + content_sha256?: string | null; + pushed_sha256?: string | null; + state: string & Opt = 'pending'; + error_code?: string | null; + attempts: number & Opt = 0; + next_attempt_at?: string | null; + remote_missing_at?: string | null; + first_seen_at!: string & Opt; + last_seen_at!: string & Opt; + synced_at?: string | null; + remote_trashed_at?: string | null; +} + +export const DocumentSyncItemsSchema = defineEntity({ + class: DocumentSyncItems, + repository: () => DocumentSyncItemsRepository, + indexes: [ + { + name: 'idx_document_sync_items_due', + properties: ['link_id', 'next_attempt_at'], + }, + { + name: 'idx_document_sync_items_hash', + properties: ['link_id', 'content_sha256'], + }, + { + name: 'idx_document_sync_items_trip_state', + properties: ['trip_id', 'state'], + }, + ], + uniques: [ + { + name: 'idx_document_sync_items_uid', + properties: ['link_id', 'trek_doc_uid'], + }, + { + name: 'idx_document_sync_items_file', + where: 'file_id IS NOT NULL', + properties: ['link_id', 'file_id'], + }, + { + name: 'idx_document_sync_items_remote', + where: 'remote_id IS NOT NULL', + properties: ['link_id', 'remote_id'], + }, + ], + properties: { + id: p.integer().primary(), + link: () => p.manyToOne(TripDocumentLinks).ref().deleteRule('cascade').hidden(), + link_id: p.integer().persist(false), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + file: () => p.manyToOne(TripFiles).ref().nullable().hidden(), + file_id: p.integer().nullable().persist(false), + trek_doc_uid: p.text(), + remote_id: p.text().nullable(), + remote_name: p.text().nullable(), + remote_version: p.text().nullable(), + remote_size: p.integer().nullable(), + remote_modified_at: p.text().nullable(), + content_sha256: p.text().nullable(), + pushed_sha256: p.text().nullable(), + state: p.text().default('pending'), + error_code: p.text().nullable(), + attempts: p.integer().default(0), + next_attempt_at: p.text().nullable(), + remote_missing_at: p.text().nullable(), + first_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + synced_at: p.text().nullable(), + remote_trashed_at: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/FileLinks.entity.ts b/server/src/db/entities/FileLinks.entity.ts new file mode 100644 index 000000000..a324b82e8 --- /dev/null +++ b/server/src/db/entities/FileLinks.entity.ts @@ -0,0 +1,52 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { FileLinksRepository } from '../repositories/FileLinks.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { TripFiles } from './TripFiles.entity'; + +export class FileLinks { + [EntityRepositoryType]?: FileLinksRepository; + id!: number & Opt; + file!: Ref; + file_id!: number; + reservation?: Ref | null; + reservation_id?: number | null; + assignment?: Ref | null; + assignment_id?: number | null; + place?: Ref | null; + place_id?: number | null; + created_at?: string | null; + budgetItem?: Ref | null; + budget_item_id?: number | null; +} + +export const FileLinksSchema = defineEntity({ + class: FileLinks, + repository: () => FileLinksRepository, + uniques: [ + { + name: 'idx_file_links_file_budget', + properties: ['file_id', 'budget_item_id'], + }, + { properties: ['file', 'place'] }, + { properties: ['file', 'assignment'] }, + { properties: ['file', 'reservation'] }, + ], + properties: { + id: p.integer().primary(), + file: () => p.manyToOne(TripFiles).ref().deleteRule('cascade').hidden(), + file_id: p.integer().persist(false), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').nullable().hidden(), + reservation_id: p.integer().nullable().persist(false), + assignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').nullable().hidden(), + assignment_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + budgetItem: () => p.manyToOne(BudgetItems).ref().nullable().hidden().index('idx_file_links_budget_item_id'), + budget_item_id: p.integer().nullable().persist(false).index('idx_file_links_budget_item_id'), + }, +}); diff --git a/server/src/db/entities/GoogleApiUsage.entity.ts b/server/src/db/entities/GoogleApiUsage.entity.ts new file mode 100644 index 000000000..c180b76f4 --- /dev/null +++ b/server/src/db/entities/GoogleApiUsage.entity.ts @@ -0,0 +1,18 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { GoogleApiUsageRepository } from '../repositories/GoogleApiUsage.repository'; + +export class GoogleApiUsage { + [EntityRepositoryType]?: GoogleApiUsageRepository; + [PrimaryKeyProp]?: 'day'; + day?: string | null; + calls: number & Opt = 0; +} + +export const GoogleApiUsageSchema = defineEntity({ + class: GoogleApiUsage, + repository: () => GoogleApiUsageRepository, + properties: { + day: p.text().primary().nullable(), + calls: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/GooglePlacePhotoMeta.entity.ts b/server/src/db/entities/GooglePlacePhotoMeta.entity.ts new file mode 100644 index 000000000..b84c86f55 --- /dev/null +++ b/server/src/db/entities/GooglePlacePhotoMeta.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { GooglePlacePhotoMetaRepository } from '../repositories/GooglePlacePhotoMeta.repository'; + +export class GooglePlacePhotoMeta { + [EntityRepositoryType]?: GooglePlacePhotoMetaRepository; + [PrimaryKeyProp]?: 'place_id'; + place_id?: string | null; + attribution?: string | null; + fetched_at!: number; + error_at?: number | null; +} + +export const GooglePlacePhotoMetaSchema = defineEntity({ + class: GooglePlacePhotoMeta, + repository: () => GooglePlacePhotoMetaRepository, + properties: { + place_id: p.text().primary().nullable(), + attribution: p.text().nullable(), + fetched_at: p.integer(), + error_at: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/HiddenCountries.entity.ts b/server/src/db/entities/HiddenCountries.entity.ts new file mode 100644 index 000000000..07ef7a8eb --- /dev/null +++ b/server/src/db/entities/HiddenCountries.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { HiddenCountriesRepository } from '../repositories/HiddenCountries.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class HiddenCountries { + [EntityRepositoryType]?: HiddenCountriesRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + country_code!: string; + created_at?: string | null; +} + +export const HiddenCountriesSchema = defineEntity({ + class: HiddenCountries, + repository: () => HiddenCountriesRepository, + uniques: [{ properties: ['user', 'country_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_countries_user'), + user_id: p.integer().persist(false).index('idx_hidden_countries_user'), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/HiddenRegions.entity.ts b/server/src/db/entities/HiddenRegions.entity.ts new file mode 100644 index 000000000..eab7a397f --- /dev/null +++ b/server/src/db/entities/HiddenRegions.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { HiddenRegionsRepository } from '../repositories/HiddenRegions.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class HiddenRegions { + [EntityRepositoryType]?: HiddenRegionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + region_code!: string; + country_code!: string; + created_at?: string | null; +} + +export const HiddenRegionsSchema = defineEntity({ + class: HiddenRegions, + repository: () => HiddenRegionsRepository, + uniques: [{ properties: ['user', 'region_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_regions_user'), + user_id: p.integer().persist(false).index('idx_hidden_regions_user'), + region_code: p.text(), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/IdempotencyKeys.entity.ts b/server/src/db/entities/IdempotencyKeys.entity.ts new file mode 100644 index 000000000..4cb740b6a --- /dev/null +++ b/server/src/db/entities/IdempotencyKeys.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { IdempotencyKeysRepository } from '../repositories/IdempotencyKeys.repository'; +import { Users } from './Users.entity'; + +export class IdempotencyKeys { + [EntityRepositoryType]?: IdempotencyKeysRepository; + [PrimaryKeyProp]?: ['key', 'user', 'method', 'path']; + key!: string; + user!: Ref; + user_id!: number; + method!: string; + path!: string; + status_code!: number; + response_body!: string; + created_at!: number & Opt; +} + +export const IdempotencyKeysSchema = defineEntity({ + class: IdempotencyKeys, + repository: () => IdempotencyKeysRepository, + uniques: [{ properties: ['key', 'user', 'method', 'path'] }], + properties: { + key: p.text().primary(), + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + method: p.text().primary(), + path: p.text().primary(), + status_code: p.integer(), + response_body: p.text(), + created_at: p.integer().defaultRaw(`(strftime('%s','now'))`).index('idx_idempotency_keys_created'), + }, +}); diff --git a/server/src/db/entities/InviteTokens.entity.ts b/server/src/db/entities/InviteTokens.entity.ts new file mode 100644 index 000000000..dbfcf7bef --- /dev/null +++ b/server/src/db/entities/InviteTokens.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { InviteTokensRepository } from '../repositories/InviteTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class InviteTokens { + [EntityRepositoryType]?: InviteTokensRepository; + id!: number & Opt; + token!: string; + max_uses: number & Opt = 1; + used_count: number & Opt = 0; + expires_at?: string | null; + created_by!: number; + created_at?: string | null; + trip?: Ref | null; + trip_id?: number | null; + createdByRef!: Ref; +} + +export const InviteTokensSchema = defineEntity({ + class: InviteTokens, + repository: () => InviteTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + token: p.text(), + max_uses: p.integer().default(1), + used_count: p.integer().default(0), + expires_at: p.text().nullable(), + created_by: p.integer().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + trip: () => p.manyToOne(Trips).ref().nullable().hidden(), + trip_id: p.integer().nullable().persist(false), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').deleteRule('cascade').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyBooks.entity.ts b/server/src/db/entities/JourneyBooks.entity.ts new file mode 100644 index 000000000..29bcdb362 --- /dev/null +++ b/server/src/db/entities/JourneyBooks.entity.ts @@ -0,0 +1,40 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyBooksRepository } from '../repositories/JourneyBooks.repository'; +import { DbTimestampType } from '../types'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyBooks { + [EntityRepositoryType]?: JourneyBooksRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + title: string & Opt = ''; + document!: string; + version: number & Opt = 1; + created_by?: number | null; + updated_by?: number | null; + created_at?: string | null; + updated_at?: string | null; + updatedByRef?: Ref | null; + createdByRef?: Ref | null; +} + +export const JourneyBooksSchema = defineEntity({ + class: JourneyBooks, + repository: () => JourneyBooksRepository, + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_journey_books_journey'), + journey_id: p.integer().persist(false).index('idx_journey_books_journey'), + title: p.text().default(''), + document: p.text(), + version: p.integer().default(1), + created_by: p.integer().nullable().persist(false), + updated_by: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updatedByRef: () => p.manyToOne(Users).ref().joinColumn('updated_by').nullable().hidden(), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyContributors.entity.ts b/server/src/db/entities/JourneyContributors.entity.ts new file mode 100644 index 000000000..f0a60a3cb --- /dev/null +++ b/server/src/db/entities/JourneyContributors.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyContributorsRepository } from '../repositories/JourneyContributors.repository'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyContributors { + [EntityRepositoryType]?: JourneyContributorsRepository; + [PrimaryKeyProp]?: ['journey', 'user']; + journey!: Ref; + journey_id!: number; + user!: Ref; + user_id!: number; + role!: string; + added_at!: number; + hide_skeletons: number & Opt = 0; +} + +export const JourneyContributorsSchema = defineEntity({ + class: JourneyContributors, + repository: () => JourneyContributorsRepository, + uniques: [{ properties: ['journey', 'user'] }], + properties: { + journey: () => p.manyToOne(Journeys).primary().ref().hidden(), + journey_id: p.integer().persist(false), + user: () => p.manyToOne(Users).primary().ref().deleteRule('no action').hidden().index('idx_journey_contributors_user'), + user_id: p.integer().persist(false).index('idx_journey_contributors_user'), + role: p.text(), + added_at: p.integer(), + hide_skeletons: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/JourneyEntries.entity.ts b/server/src/db/entities/JourneyEntries.entity.ts new file mode 100644 index 000000000..ee24b7b41 --- /dev/null +++ b/server/src/db/entities/JourneyEntries.entity.ts @@ -0,0 +1,94 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyEntriesRepository } from '../repositories/JourneyEntries.repository'; +import { JourneyEntryPhotos } from './JourneyEntryPhotos.entity'; +import { Journeys } from './Journeys.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class JourneyEntries { + [EntityRepositoryType]?: JourneyEntriesRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + sourceTrip?: Ref | null; + source_trip_id?: number | null; + sourcePlace?: Ref | null; + source_place_id?: number | null; + author!: Ref; + author_id!: number; + type!: string; + title?: string | null; + story?: string | null; + entry_date!: string; + entry_time?: string | null; + location_name?: string | null; + location_lat?: number | null; + location_lng?: number | null; + mood?: string | null; + weather?: string | null; + tags?: string | null; + visibility?: string | null = 'private'; + sort_order?: number | null = 0; + created_at!: number; + updated_at!: number; + pros_cons?: string | null; + stats_excluded: number & Opt = 0; + dismissed: number & Opt = 0; + country_code?: string | null; + source_assignment_id?: number | null; + is_draft: number & Opt = 0; + journey_entry_photos_collection = new Collection(this); +} + +export const JourneyEntriesSchema = defineEntity({ + class: JourneyEntries, + repository: () => JourneyEntriesRepository, + indexes: [ + { + name: 'idx_journey_entries_source_assignment', + properties: ['source_place_id', 'source_assignment_id'], + }, + { + name: 'idx_journey_entries_order', + properties: ['journey_id', 'entry_date', 'sort_order'], + }, + { + name: 'idx_journey_entries_journey', + properties: ['journey_id', 'entry_date'], + }, + ], + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden(), + journey_id: p.integer().persist(false), + sourceTrip: () => p.manyToOne(Trips).ref().nullable().hidden(), + source_trip_id: p.integer().nullable().persist(false), + sourcePlace: () => p.manyToOne(Places).ref().nullable().hidden().index('idx_journey_entries_source'), + source_place_id: p.integer().nullable().persist(false).index('idx_journey_entries_source'), + author: () => p.manyToOne(Users).ref().hidden(), + author_id: p.integer().persist(false), + type: p.text(), + title: p.text().nullable(), + story: p.text().nullable(), + entry_date: p.text(), + entry_time: p.text().nullable(), + location_name: p.text().nullable(), + location_lat: p.double().nullable(), + location_lng: p.double().nullable(), + mood: p.text().nullable(), + weather: p.text().nullable(), + tags: p.text().nullable(), + visibility: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.integer(), + updated_at: p.integer(), + pros_cons: p.text().nullable(), + stats_excluded: p.integer().default(0), + dismissed: p.integer().default(0), + country_code: p.text().nullable(), + source_assignment_id: p.integer().nullable(), + is_draft: p.integer().default(0), + journey_entry_photos_collection: () => p.oneToMany(JourneyEntryPhotos).mappedBy('entry').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyEntryPhotos.entity.ts b/server/src/db/entities/JourneyEntryPhotos.entity.ts new file mode 100644 index 000000000..4f10cde35 --- /dev/null +++ b/server/src/db/entities/JourneyEntryPhotos.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyEntryPhotosRepository } from '../repositories/JourneyEntryPhotos.repository'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyPhotos } from './JourneyPhotos.entity'; + +export class JourneyEntryPhotos { + [EntityRepositoryType]?: JourneyEntryPhotosRepository; + [PrimaryKeyProp]?: ['entry', 'journeyPhoto']; + entry!: Ref; + entry_id!: number; + journeyPhoto!: Ref; + journey_photo_id!: number; + sort_order?: number | null = 0; + created_at!: number; +} + +export const JourneyEntryPhotosSchema = defineEntity({ + class: JourneyEntryPhotos, + repository: () => JourneyEntryPhotosRepository, + uniques: [{ properties: ['entry', 'journeyPhoto'] }], + properties: { + entry: () => p.manyToOne(JourneyEntries).primary().ref().hidden().index('idx_journey_entry_photos_entry'), + entry_id: p.integer().persist(false).index('idx_journey_entry_photos_entry'), + journeyPhoto: () => p.manyToOne(JourneyPhotos).primary().ref().hidden().index('idx_journey_entry_photos_photo'), + journey_photo_id: p.integer().persist(false).index('idx_journey_entry_photos_photo'), + sort_order: p.integer().nullable(), + created_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/JourneyPhotos.entity.ts b/server/src/db/entities/JourneyPhotos.entity.ts new file mode 100644 index 000000000..590e21679 --- /dev/null +++ b/server/src/db/entities/JourneyPhotos.entity.ts @@ -0,0 +1,43 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyPhotosRepository } from '../repositories/JourneyPhotos.repository'; +import { JourneyEntryPhotos } from './JourneyEntryPhotos.entity'; +import { Journeys } from './Journeys.entity'; +import { TrekPhotos } from './TrekPhotos.entity'; + +export class JourneyPhotos { + [EntityRepositoryType]?: JourneyPhotosRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + photo!: Ref; + photo_id!: number; + caption?: string | null; + shared?: number | null = 0; + sort_order?: number | null = 0; + provider?: string | null; + asset_id?: string | null; + owner_id?: number | null; + created_at!: number; + journey_entry_photos_collection = new Collection(this); +} + +export const JourneyPhotosSchema = defineEntity({ + class: JourneyPhotos, + repository: () => JourneyPhotosRepository, + uniques: [{ properties: ['journey', 'photo'] }], + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_journey_photos_journey'), + journey_id: p.integer().persist(false).index('idx_journey_photos_journey'), + photo: () => p.manyToOne(TrekPhotos).ref().deleteRule('cascade').hidden(), + photo_id: p.integer().persist(false), + caption: p.text().nullable(), + shared: p.integer().nullable(), + sort_order: p.integer().nullable(), + provider: p.text().nullable(), + asset_id: p.text().nullable(), + owner_id: p.integer().nullable(), + created_at: p.integer(), + journey_entry_photos_collection: () => p.oneToMany(JourneyEntryPhotos).mappedBy('journeyPhoto').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyShareTokens.entity.ts b/server/src/db/entities/JourneyShareTokens.entity.ts new file mode 100644 index 000000000..ecc9d9117 --- /dev/null +++ b/server/src/db/entities/JourneyShareTokens.entity.ts @@ -0,0 +1,39 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyShareTokensRepository } from '../repositories/JourneyShareTokens.repository'; +import { DbTimestampType } from '../types'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyShareTokens { + [EntityRepositoryType]?: JourneyShareTokensRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + token!: string; + created_by!: number; + share_timeline?: number | null = 1; + share_gallery?: number | null = 1; + share_map?: number | null = 1; + created_at?: string | null; + newest_first: number & Opt = 0; + createdByRef!: Ref; +} + +export const JourneyShareTokensSchema = defineEntity({ + class: JourneyShareTokens, + repository: () => JourneyShareTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + journey: () => p.oneToOne(Journeys).ref().deleteRule('cascade').hidden().unique('idx_journey_share_journey'), + journey_id: p.integer().persist(false).unique('idx_journey_share_journey'), + token: p.text(), + created_by: p.integer().persist(false), + share_timeline: p.integer().nullable(), + share_gallery: p.integer().nullable(), + share_map: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + newest_first: p.integer().default(0), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyTrips.entity.ts b/server/src/db/entities/JourneyTrips.entity.ts new file mode 100644 index 000000000..a55b1ac54 --- /dev/null +++ b/server/src/db/entities/JourneyTrips.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyTripsRepository } from '../repositories/JourneyTrips.repository'; +import { Journeys } from './Journeys.entity'; +import { Trips } from './Trips.entity'; + +export class JourneyTrips { + [EntityRepositoryType]?: JourneyTripsRepository; + [PrimaryKeyProp]?: ['journey', 'trip']; + journey!: Ref; + journey_id!: number; + trip!: Ref; + trip_id!: number; + added_at!: number; +} + +export const JourneyTripsSchema = defineEntity({ + class: JourneyTrips, + repository: () => JourneyTripsRepository, + uniques: [{ properties: ['journey', 'trip'] }], + properties: { + journey: () => p.manyToOne(Journeys).primary().ref().hidden().index('idx_journey_trips_journey'), + journey_id: p.integer().persist(false).index('idx_journey_trips_journey'), + trip: () => p.manyToOne(Trips).primary().ref().hidden(), + trip_id: p.integer().persist(false), + added_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/Journeys.entity.ts b/server/src/db/entities/Journeys.entity.ts new file mode 100644 index 000000000..d553100b2 --- /dev/null +++ b/server/src/db/entities/Journeys.entity.ts @@ -0,0 +1,64 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneysRepository } from '../repositories/Journeys.repository'; +import { JourneyBooks } from './JourneyBooks.entity'; +import { JourneyContributors } from './JourneyContributors.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyPhotos } from './JourneyPhotos.entity'; +import { JourneyShareTokens } from './JourneyShareTokens.entity'; +import { JourneyTrips } from './JourneyTrips.entity'; +import { Users } from './Users.entity'; + +export class Journeys { + [EntityRepositoryType]?: JourneysRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + title!: string; + subtitle?: string | null; + cover_gradient?: string | null; + status?: string | null = 'draft'; + created_at!: number; + updated_at!: number; + cover_image?: string | null; + show_trip_tracks: number & Opt = 0; + show_verdict: number & Opt = 1; + show_mood: number & Opt = 1; + show_weather: number & Opt = 1; + status_override?: string | null; + photo_location: number & Opt = 0; + journey_books_collection = new Collection(this); + journey_contributors_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_photos_collection = new Collection(this); + journey_share_tokens: Ref | null = null; + journey_trips_collection = new Collection(this); +} + +export const JourneysSchema = defineEntity({ + class: Journeys, + repository: () => JourneysRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().hidden().index('idx_journeys_user'), + user_id: p.integer().persist(false).index('idx_journeys_user'), + title: p.text(), + subtitle: p.text().nullable(), + cover_gradient: p.text().nullable(), + status: p.text().nullable(), + created_at: p.integer(), + updated_at: p.integer(), + cover_image: p.text().nullable(), + show_trip_tracks: p.integer().default(0), + show_verdict: p.integer().default(1), + show_mood: p.integer().default(1), + show_weather: p.integer().default(1), + status_override: p.text().nullable(), + photo_location: p.integer().default(0), + journey_books_collection: () => p.oneToMany(JourneyBooks).mappedBy('journey').hidden(), + journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('journey').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('journey').hidden(), + journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(), + journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(), + journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(), + }, +}); diff --git a/server/src/db/entities/McpTokens.entity.ts b/server/src/db/entities/McpTokens.entity.ts new file mode 100644 index 000000000..d3c8421c2 --- /dev/null +++ b/server/src/db/entities/McpTokens.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { McpTokensRepository } from '../repositories/McpTokens.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class McpTokens { + [EntityRepositoryType]?: McpTokensRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + token_hash!: string; + token_prefix!: string; + created_at?: string | null; + last_used_at?: string | null; + kind: string & Opt = 'mcp'; + scope_mode: string & Opt = 'all'; + api_scopes?: string | null; +} + +export const McpTokensSchema = defineEntity({ + class: McpTokens, + repository: () => McpTokensRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + token_hash: p.text().unique('idx_mcp_tokens_hash'), + token_prefix: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + last_used_at: p.type(DbTimestampType).nullable(), + kind: p.text().default('mcp'), + scope_mode: p.text().default('all'), + api_scopes: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Migrations.entity.ts b/server/src/db/entities/Migrations.entity.ts new file mode 100644 index 000000000..1c4570f95 --- /dev/null +++ b/server/src/db/entities/Migrations.entity.ts @@ -0,0 +1,19 @@ +import { EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { MigrationsRepository } from '../repositories/Migrations.repository'; + +export class Migrations { + [EntityRepositoryType]?: MigrationsRepository; + id!: number; + timestamp!: bigint; + name!: string; +} + +export const MigrationsSchema = defineEntity({ + class: Migrations, + repository: () => MigrationsRepository, + properties: { + id: p.integer().primary(), + timestamp: p.bigint(), + name: p.string(), + }, +}); diff --git a/server/src/db/entities/NotificationChannelPreferences.entity.ts b/server/src/db/entities/NotificationChannelPreferences.entity.ts new file mode 100644 index 000000000..478d1acf6 --- /dev/null +++ b/server/src/db/entities/NotificationChannelPreferences.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { NotificationChannelPreferencesRepository } from '../repositories/NotificationChannelPreferences.repository'; +import { Users } from './Users.entity'; + +export class NotificationChannelPreferences { + [EntityRepositoryType]?: NotificationChannelPreferencesRepository; + [PrimaryKeyProp]?: ['user', 'event_type', 'channel']; + user!: Ref; + user_id!: number; + event_type!: string; + channel!: string; + enabled: number & Opt = 1; +} + +export const NotificationChannelPreferencesSchema = defineEntity({ + class: NotificationChannelPreferences, + repository: () => NotificationChannelPreferencesRepository, + uniques: [{ properties: ['user', 'event_type', 'channel'] }], + properties: { + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden().index('idx_ncp_user'), + user_id: p.integer().persist(false).index('idx_ncp_user'), + event_type: p.text().primary(), + channel: p.text().primary(), + enabled: p.integer().default(1), + }, +}); diff --git a/server/src/db/entities/Notifications.entity.ts b/server/src/db/entities/Notifications.entity.ts new file mode 100644 index 000000000..775075d67 --- /dev/null +++ b/server/src/db/entities/Notifications.entity.ts @@ -0,0 +1,68 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { NotificationsRepository } from '../repositories/Notifications.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class Notifications { + [EntityRepositoryType]?: NotificationsRepository; + id!: number & Opt; + type!: string; + scope!: string; + target!: number; + sender?: Ref | null; + sender_id?: number | null; + recipient!: Ref; + recipient_id!: number; + title_key!: string; + title_params?: string | null = '{}'; + text_key!: string; + text_params?: string | null = '{}'; + positive_text_key?: string | null; + negative_text_key?: string | null; + positive_callback?: string | null; + negative_callback?: string | null; + response?: string | null; + navigate_text_key?: string | null; + navigate_target?: string | null; + is_read?: number | null = 0; + created_at?: string | null; +} + +export const NotificationsSchema = defineEntity({ + class: Notifications, + repository: () => NotificationsRepository, + indexes: [ + { name: 'idx_notifications_target_scope', properties: ['target', 'scope'] }, + { + name: 'idx_notifications_recipient_created', + properties: ['recipient_id', 'created_at'], + }, + { + name: 'idx_notifications_recipient', + properties: ['recipient_id', 'is_read', 'created_at'], + }, + ], + properties: { + id: p.integer().primary(), + type: p.text(), + scope: p.text(), + target: p.integer(), + sender: () => p.manyToOne(Users).ref().nullable().hidden(), + sender_id: p.integer().nullable().persist(false), + recipient: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + recipient_id: p.integer().persist(false), + title_key: p.text(), + title_params: p.text().nullable(), + text_key: p.text(), + text_params: p.text().nullable(), + positive_text_key: p.text().nullable(), + negative_text_key: p.text().nullable(), + positive_callback: p.text().nullable(), + negative_callback: p.text().nullable(), + response: p.text().nullable(), + navigate_text_key: p.text().nullable(), + navigate_target: p.text().nullable(), + is_read: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/OauthClients.entity.ts b/server/src/db/entities/OauthClients.entity.ts new file mode 100644 index 000000000..03fb23478 --- /dev/null +++ b/server/src/db/entities/OauthClients.entity.ts @@ -0,0 +1,46 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthClientsRepository } from '../repositories/OauthClients.repository'; +import { DbTimestampType } from '../types'; +import { OauthConsents } from './OauthConsents.entity'; +import { OauthTokens } from './OauthTokens.entity'; +import { Users } from './Users.entity'; + +export class OauthClients { + [EntityRepositoryType]?: OauthClientsRepository; + id?: string | null; + user?: Ref | null; + user_id?: number | null; + name!: string; + client_id!: string; + client_secret_hash!: string; + redirect_uris: string & Opt = '[]'; + allowed_scopes: string & Opt = '[]'; + created_at?: string | null; + is_public: number & Opt = 0; + created_via: string & Opt = 'settings_ui'; + allows_client_credentials: number & Opt = 0; + oauth_consents_collection = new Collection(this); + oauth_tokens_collection = new Collection(this); +} + +export const OauthClientsSchema = defineEntity({ + class: OauthClients, + repository: () => OauthClientsRepository, + uniques: [{ properties: ['client_id'] }], + properties: { + id: p.text().primary().nullable(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden().index('idx_oauth_clients_user'), + user_id: p.integer().nullable().persist(false).index('idx_oauth_clients_user'), + name: p.text(), + client_id: p.text().unique('idx_oauth_clients_client_id'), + client_secret_hash: p.text(), + redirect_uris: p.text().default('[]'), + allowed_scopes: p.text().default('[]'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + is_public: p.integer().default(0), + created_via: p.text().default('settings_ui'), + allows_client_credentials: p.integer().default(0), + oauth_consents_collection: () => p.oneToMany(OauthConsents).mappedBy('client').hidden(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('client').hidden(), + }, +}); diff --git a/server/src/db/entities/OauthConsents.entity.ts b/server/src/db/entities/OauthConsents.entity.ts new file mode 100644 index 000000000..71d867469 --- /dev/null +++ b/server/src/db/entities/OauthConsents.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthConsentsRepository } from '../repositories/OauthConsents.repository'; +import { DbTimestampType } from '../types'; +import { OauthClients } from './OauthClients.entity'; +import { Users } from './Users.entity'; + +export class OauthConsents { + [EntityRepositoryType]?: OauthConsentsRepository; + id!: number & Opt; + client!: Ref; + client_id!: string; + user!: Ref; + user_id!: number; + scopes: string & Opt = '[]'; + updated_at?: string | null; +} + +export const OauthConsentsSchema = defineEntity({ + class: OauthConsents, + repository: () => OauthConsentsRepository, + uniques: [{ properties: ['client', 'user'] }], + properties: { + id: p.integer().primary(), + client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'), + client_id: p.text().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + scopes: p.text().default('[]'), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/OauthTokens.entity.ts b/server/src/db/entities/OauthTokens.entity.ts new file mode 100644 index 000000000..b7e912614 --- /dev/null +++ b/server/src/db/entities/OauthTokens.entity.ts @@ -0,0 +1,52 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthTokensRepository } from '../repositories/OauthTokens.repository'; +import { DbTimestampType } from '../types'; +import { OauthClients } from './OauthClients.entity'; +import { Users } from './Users.entity'; + +export class OauthTokens { + [EntityRepositoryType]?: OauthTokensRepository; + id!: number & Opt; + client!: Ref; + client_id!: string; + user!: Ref; + user_id!: number; + access_token_hash!: string; + refresh_token_hash!: string; + scopes: string & Opt = '[]'; + access_token_expires_at!: string; + refresh_token_expires_at!: string; + revoked_at?: string | null; + created_at?: string | null; + parentToken?: Ref | null; + parent_token_id?: number | null; + audience?: string | null; + oauth_tokens_collection = new Collection(this); +} + +export const OauthTokensSchema = defineEntity({ + class: OauthTokens, + repository: () => OauthTokensRepository, + uniques: [ + { properties: ['refresh_token_hash'] }, + { properties: ['access_token_hash'] }, + ], + properties: { + id: p.integer().primary(), + client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'), + client_id: p.text().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_oauth_tokens_user'), + user_id: p.integer().persist(false).index('idx_oauth_tokens_user'), + access_token_hash: p.text().unique('idx_oauth_tokens_access'), + refresh_token_hash: p.text().unique('idx_oauth_tokens_refresh'), + scopes: p.text().default('[]'), + access_token_expires_at: p.type(DbTimestampType), + refresh_token_expires_at: p.type(DbTimestampType), + revoked_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + parentToken: () => p.manyToOne(OauthTokens).ref().deleteRule('no action').nullable().hidden().index('idx_oauth_tokens_parent'), + parent_token_id: p.integer().nullable().persist(false).index('idx_oauth_tokens_parent'), + audience: p.text().nullable(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('parentToken').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingBags.entity.ts b/server/src/db/entities/PackingBags.entity.ts new file mode 100644 index 000000000..815907046 --- /dev/null +++ b/server/src/db/entities/PackingBags.entity.ts @@ -0,0 +1,41 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingBagsRepository } from '../repositories/PackingBags.repository'; +import { DbTimestampType } from '../types'; +import { PackingItems } from './PackingItems.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingBags { + [EntityRepositoryType]?: PackingBagsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + color: string & Opt = '#6366f1'; + weight_limit_grams?: number | null; + sort_order?: number | null = 0; + created_at?: string | null; + user?: Ref | null; + user_id?: number | null; + packing_bag_members = new Collection(this); + packing_items_collection = new Collection(this); +} + +export const PackingBagsSchema = defineEntity({ + class: PackingBags, + repository: () => PackingBagsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + name: p.text(), + color: p.text().default('#6366f1'), + weight_limit_grams: p.integer().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + user: () => p.manyToOne(Users).ref().nullable().hidden().defaultRaw(`NULL`), + user_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + packing_bag_members: () => p.manyToMany(Users).pivotTable('packing_bag_members').joinColumn('bag_id').inverseJoinColumn('user_id').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('bag').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingCategoryAssignees.entity.ts b/server/src/db/entities/PackingCategoryAssignees.entity.ts new file mode 100644 index 000000000..d88548fef --- /dev/null +++ b/server/src/db/entities/PackingCategoryAssignees.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingCategoryAssigneesRepository } from '../repositories/PackingCategoryAssignees.repository'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingCategoryAssignees { + [EntityRepositoryType]?: PackingCategoryAssigneesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category_name!: string; + user!: Ref; + user_id!: number; +} + +export const PackingCategoryAssigneesSchema = defineEntity({ + class: PackingCategoryAssignees, + repository: () => PackingCategoryAssigneesRepository, + uniques: [{ properties: ['trip', 'category_name', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category_name: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/PackingItemContributors.entity.ts b/server/src/db/entities/PackingItemContributors.entity.ts new file mode 100644 index 000000000..315927fcb --- /dev/null +++ b/server/src/db/entities/PackingItemContributors.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingItemContributorsRepository } from '../repositories/PackingItemContributors.repository'; +import { DbTimestampType } from '../types'; +import { PackingItems } from './PackingItems.entity'; +import { Users } from './Users.entity'; + +export class PackingItemContributors { + [EntityRepositoryType]?: PackingItemContributorsRepository; + [PrimaryKeyProp]?: ['item', 'user']; + item!: Ref; + item_id!: number; + user!: Ref; + user_id!: number; + status: string & Opt = 'accepted'; + created_at?: string | null; +} + +export const PackingItemContributorsSchema = defineEntity({ + class: PackingItemContributors, + repository: () => PackingItemContributorsRepository, + uniques: [{ properties: ['item', 'user'] }], + properties: { + item: () => p.manyToOne(PackingItems).primary().ref().hidden(), + item_id: p.integer().persist(false), + user: () => p.manyToOne(Users).primary().ref().hidden(), + user_id: p.integer().persist(false), + status: p.text().default('accepted'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PackingItems.entity.ts b/server/src/db/entities/PackingItems.entity.ts new file mode 100644 index 000000000..94b422f2b --- /dev/null +++ b/server/src/db/entities/PackingItems.entity.ts @@ -0,0 +1,58 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingItemsRepository } from '../repositories/PackingItems.repository'; +import { DbTimestampType } from '../types'; +import { PackingBags } from './PackingBags.entity'; +import { PackingItemContributors } from './PackingItemContributors.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingItems { + [EntityRepositoryType]?: PackingItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + checked?: number | null = 0; + category?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + weight_grams?: number | null; + bag?: Ref | null; + bag_id?: number | null; + quantity: number & Opt = 1; + updated_at?: string | null; + is_private: number & Opt = 0; + owner?: Ref | null; + owner_id?: number | null; + packed_quantity?: number | null; + packing_item_contributors = new Collection(this); + packing_item_recipients = new Collection(this); + packing_item_contributors_collection = new Collection(this); +} + +export const PackingItemsSchema = defineEntity({ + class: PackingItems, + repository: () => PackingItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_packing_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_packing_items_trip_id'), + name: p.text(), + checked: p.integer().nullable(), + category: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + weight_grams: p.integer().nullable(), + bag: () => p.manyToOne(PackingBags).ref().nullable().hidden(), + bag_id: p.integer().nullable().persist(false), + quantity: p.integer().default(1), + updated_at: p.type(DbTimestampType).nullable(), + is_private: p.integer().default(0), + owner: () => p.manyToOne(Users).ref().nullable().hidden(), + owner_id: p.integer().nullable().persist(false), + packed_quantity: p.integer().nullable(), + packing_item_contributors: () => p.manyToMany(Users).pivotTable('packing_item_contributors').pivotEntity(() => PackingItemContributors).joinColumn('item_id').inverseJoinColumn('user_id').hidden(), + packing_item_recipients: () => p.manyToMany(Users).pivotTable('packing_item_recipients').joinColumn('item_id').inverseJoinColumn('user_id').hidden(), + packing_item_contributors_collection: () => p.oneToMany(PackingItemContributors).mappedBy('item').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingTemplateCategories.entity.ts b/server/src/db/entities/PackingTemplateCategories.entity.ts new file mode 100644 index 000000000..748f4d3d0 --- /dev/null +++ b/server/src/db/entities/PackingTemplateCategories.entity.ts @@ -0,0 +1,27 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplateCategoriesRepository } from '../repositories/PackingTemplateCategories.repository'; +import { PackingTemplateItems } from './PackingTemplateItems.entity'; +import { PackingTemplates } from './PackingTemplates.entity'; + +export class PackingTemplateCategories { + [EntityRepositoryType]?: PackingTemplateCategoriesRepository; + id!: number & Opt; + template!: Ref; + template_id!: number; + name!: string; + sort_order: number & Opt = 0; + packing_template_items_collection = new Collection(this); +} + +export const PackingTemplateCategoriesSchema = defineEntity({ + class: PackingTemplateCategories, + repository: () => PackingTemplateCategoriesRepository, + properties: { + id: p.integer().primary(), + template: () => p.manyToOne(PackingTemplates).ref().deleteRule('cascade').hidden(), + template_id: p.integer().persist(false), + name: p.text(), + sort_order: p.integer().default(0), + packing_template_items_collection: () => p.oneToMany(PackingTemplateItems).mappedBy('category').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingTemplateItems.entity.ts b/server/src/db/entities/PackingTemplateItems.entity.ts new file mode 100644 index 000000000..891ee4baa --- /dev/null +++ b/server/src/db/entities/PackingTemplateItems.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplateItemsRepository } from '../repositories/PackingTemplateItems.repository'; +import { PackingTemplateCategories } from './PackingTemplateCategories.entity'; + +export class PackingTemplateItems { + [EntityRepositoryType]?: PackingTemplateItemsRepository; + id!: number & Opt; + category!: Ref; + category_id!: number; + name!: string; + sort_order: number & Opt = 0; + weight_grams?: number | null; + quantity: number & Opt = 1; + bag_name?: string | null; +} + +export const PackingTemplateItemsSchema = defineEntity({ + class: PackingTemplateItems, + repository: () => PackingTemplateItemsRepository, + properties: { + id: p.integer().primary(), + category: () => p.manyToOne(PackingTemplateCategories).ref().deleteRule('cascade').hidden(), + category_id: p.integer().persist(false), + name: p.text(), + sort_order: p.integer().default(0), + weight_grams: p.integer().nullable(), + quantity: p.integer().default(1), + bag_name: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PackingTemplates.entity.ts b/server/src/db/entities/PackingTemplates.entity.ts new file mode 100644 index 000000000..fc35b6231 --- /dev/null +++ b/server/src/db/entities/PackingTemplates.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplatesRepository } from '../repositories/PackingTemplates.repository'; +import { DbTimestampType } from '../types'; +import { PackingTemplateCategories } from './PackingTemplateCategories.entity'; +import { Users } from './Users.entity'; + +export class PackingTemplates { + [EntityRepositoryType]?: PackingTemplatesRepository; + id!: number & Opt; + name!: string; + created_by!: number; + created_at?: string | null; + createdByRef!: Ref; + packing_template_categories_collection = new Collection(this); +} + +export const PackingTemplatesSchema = defineEntity({ + class: PackingTemplates, + repository: () => PackingTemplatesRepository, + properties: { + id: p.integer().primary(), + name: p.text(), + created_by: p.integer().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').deleteRule('cascade').hidden(), + packing_template_categories_collection: () => p.oneToMany(PackingTemplateCategories).mappedBy('template').hidden(), + }, +}); diff --git a/server/src/db/entities/PasswordResetTokens.entity.ts b/server/src/db/entities/PasswordResetTokens.entity.ts new file mode 100644 index 000000000..8f015cdfc --- /dev/null +++ b/server/src/db/entities/PasswordResetTokens.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PasswordResetTokensRepository } from '../repositories/PasswordResetTokens.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class PasswordResetTokens { + [EntityRepositoryType]?: PasswordResetTokensRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + token_hash!: string; + expires_at!: string; + consumed_at?: string | null; + created_at?: string | null; + created_ip?: string | null; +} + +export const PasswordResetTokensSchema = defineEntity({ + class: PasswordResetTokens, + repository: () => PasswordResetTokensRepository, + uniques: [{ properties: ['token_hash'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_prt_user'), + user_id: p.integer().persist(false).index('idx_prt_user'), + token_hash: p.text().index('idx_prt_hash'), + expires_at: p.type(DbTimestampType), + consumed_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + created_ip: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PhotoProviderFields.entity.ts b/server/src/db/entities/PhotoProviderFields.entity.ts new file mode 100644 index 000000000..27758e7fc --- /dev/null +++ b/server/src/db/entities/PhotoProviderFields.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PhotoProviderFieldsRepository } from '../repositories/PhotoProviderFields.repository'; +import { PhotoProviders } from './PhotoProviders.entity'; + +export class PhotoProviderFields { + [EntityRepositoryType]?: PhotoProviderFieldsRepository; + id!: number & Opt; + provider!: Ref; + provider_id!: string; + field_key!: string; + label!: string; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required?: number | null = 0; + secret?: number | null = 0; + settings_key?: string | null; + payload_key?: string | null; + sort_order?: number | null = 0; +} + +export const PhotoProviderFieldsSchema = defineEntity({ + class: PhotoProviderFields, + repository: () => PhotoProviderFieldsRepository, + uniques: [{ properties: ['provider', 'field_key'] }], + properties: { + id: p.integer().primary(), + provider: () => p.manyToOne(PhotoProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + field_key: p.text(), + label: p.text(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().nullable(), + secret: p.integer().nullable(), + settings_key: p.text().nullable(), + payload_key: p.text().nullable(), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/PhotoProviders.entity.ts b/server/src/db/entities/PhotoProviders.entity.ts new file mode 100644 index 000000000..9cb2dd746 --- /dev/null +++ b/server/src/db/entities/PhotoProviders.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { PhotoProvidersRepository } from '../repositories/PhotoProviders.repository'; +import { PhotoProviderFields } from './PhotoProviderFields.entity'; + +export class PhotoProviders { + [EntityRepositoryType]?: PhotoProvidersRepository; + id?: string | null; + name!: string; + description?: string | null; + icon?: string | null = 'Image'; + enabled?: number | null = 0; + sort_order?: number | null = 0; + photo_provider_fields_collection = new Collection(this); +} + +export const PhotoProvidersSchema = defineEntity({ + class: PhotoProviders, + repository: () => PhotoProvidersRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + icon: p.text().nullable(), + enabled: p.integer().nullable(), + sort_order: p.integer().nullable(), + photo_provider_fields_collection: () => p.oneToMany(PhotoProviderFields).mappedBy('provider').hidden(), + }, +}); diff --git a/server/src/db/entities/Photos.entity.ts b/server/src/db/entities/Photos.entity.ts new file mode 100644 index 000000000..d64376d2a --- /dev/null +++ b/server/src/db/entities/Photos.entity.ts @@ -0,0 +1,45 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PhotosRepository } from '../repositories/Photos.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; + +export class Photos { + [EntityRepositoryType]?: PhotosRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day?: Ref | null; + day_id?: number | null; + place?: Ref | null; + place_id?: number | null; + filename!: string; + original_name!: string; + file_size?: number | null; + mime_type?: string | null; + caption?: string | null; + taken_at?: string | null; + created_at?: string | null; +} + +export const PhotosSchema = defineEntity({ + class: Photos, + repository: () => PhotosRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_photos_trip_id'), + trip_id: p.integer().persist(false).index('idx_photos_trip_id'), + day: () => p.manyToOne(Days).ref().nullable().hidden().index('idx_photos_day_id'), + day_id: p.integer().nullable().persist(false).index('idx_photos_day_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden().index('idx_photos_place_id'), + place_id: p.integer().nullable().persist(false).index('idx_photos_place_id'), + filename: p.text(), + original_name: p.text(), + file_size: p.integer().nullable(), + mime_type: p.text().nullable(), + caption: p.text().nullable(), + taken_at: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PlaceDetailsCache.entity.ts b/server/src/db/entities/PlaceDetailsCache.entity.ts new file mode 100644 index 000000000..90adba03d --- /dev/null +++ b/server/src/db/entities/PlaceDetailsCache.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PlaceDetailsCacheRepository } from '../repositories/PlaceDetailsCache.repository'; + +export class PlaceDetailsCache { + [EntityRepositoryType]?: PlaceDetailsCacheRepository; + [PrimaryKeyProp]?: ['place_id', 'lang', 'expanded']; + place_id!: string; + lang: string & Opt = ''; + expanded: number & Opt = 0; + payload_json!: string; + fetched_at!: number; +} + +export const PlaceDetailsCacheSchema = defineEntity({ + class: PlaceDetailsCache, + repository: () => PlaceDetailsCacheRepository, + properties: { + place_id: p.text().primary(), + lang: p.text().primary().default(''), + expanded: p.integer().primary().default(0), + payload_json: p.text(), + fetched_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/PlaceRatings.entity.ts b/server/src/db/entities/PlaceRatings.entity.ts new file mode 100644 index 000000000..dbaa519d7 --- /dev/null +++ b/server/src/db/entities/PlaceRatings.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlaceRatingsRepository } from '../repositories/PlaceRatings.repository'; +import { DbTimestampType } from '../types'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class PlaceRatings { + [EntityRepositoryType]?: PlaceRatingsRepository; + id!: number & Opt; + place!: Ref; + place_id!: number; + user!: Ref; + user_id!: number; + rating!: number; + created_at?: string | null; +} + +export const PlaceRatingsSchema = defineEntity({ + class: PlaceRatings, + repository: () => PlaceRatingsRepository, + uniques: [{ properties: ['place', 'user'] }], + properties: { + id: p.integer().primary(), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_place_ratings_place'), + place_id: p.integer().persist(false).index('idx_place_ratings_place'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + rating: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PlaceRegions.entity.ts b/server/src/db/entities/PlaceRegions.entity.ts new file mode 100644 index 000000000..11beadcf7 --- /dev/null +++ b/server/src/db/entities/PlaceRegions.entity.ts @@ -0,0 +1,25 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlaceRegionsRepository } from '../repositories/PlaceRegions.repository'; +import { Places } from './Places.entity'; + +export class PlaceRegions { + [EntityRepositoryType]?: PlaceRegionsRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + country_code!: string; + region_code!: string; + region_name!: string; +} + +export const PlaceRegionsSchema = defineEntity({ + class: PlaceRegions, + repository: () => PlaceRegionsRepository, + properties: { + place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + country_code: p.text().index('idx_place_regions_country'), + region_code: p.text().index('idx_place_regions_region'), + region_name: p.text(), + }, +}); diff --git a/server/src/db/entities/PlaceShadowPicks.entity.ts b/server/src/db/entities/PlaceShadowPicks.entity.ts new file mode 100644 index 000000000..d26ef5ee3 --- /dev/null +++ b/server/src/db/entities/PlaceShadowPicks.entity.ts @@ -0,0 +1,39 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PlaceShadowPicksRepository } from '../repositories/PlaceShadowPicks.repository'; + +export class PlaceShadowPicks { + [EntityRepositoryType]?: PlaceShadowPicksRepository; + id!: number & Opt; + created_at!: string & Opt; + query!: string; + lang?: string | null; + bias_lat?: number | null; + bias_lng?: number | null; + source!: string; + live_rank!: number; + live_count!: number; + picked_name!: string; + picked_lat!: number; + picked_lng!: number; + picked_place_id?: string | null; +} + +export const PlaceShadowPicksSchema = defineEntity({ + class: PlaceShadowPicks, + repository: () => PlaceShadowPicksRepository, + properties: { + id: p.integer().primary(), + created_at: p.text().defaultRaw(`(datetime('now'))`).index('idx_place_shadow_created'), + query: p.text(), + lang: p.text().nullable(), + bias_lat: p.double().nullable(), + bias_lng: p.double().nullable(), + source: p.text(), + live_rank: p.integer(), + live_count: p.integer(), + picked_name: p.text(), + picked_lat: p.double(), + picked_lng: p.double(), + picked_place_id: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Places.entity.ts b/server/src/db/entities/Places.entity.ts new file mode 100644 index 000000000..2f867ab74 --- /dev/null +++ b/server/src/db/entities/Places.entity.ts @@ -0,0 +1,127 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlacesRepository } from '../repositories/Places.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { Categories } from './Categories.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { FileLinks } from './FileLinks.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { Photos } from './Photos.entity'; +import { PlaceRatings } from './PlaceRatings.entity'; +import { PlaceRegions } from './PlaceRegions.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayTracks } from './RoadtripDayTracks.entity'; +import { Tags } from './Tags.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class Places { + [EntityRepositoryType]?: PlacesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + description?: string | null; + lat?: number | null; + lng?: number | null; + address?: string | null; + category?: Ref | null; + category_id?: number | null; + price?: number | null; + currency?: string | null; + reservation_status?: string | null = 'none'; + reservation_notes?: string | null; + reservation_datetime?: string | null; + place_time?: string | null; + end_time?: string | null; + duration_minutes?: number | null = 60; + notes?: string | null; + image_url?: string | null; + google_place_id?: string | null; + google_ftid?: string | null; + website?: string | null; + phone?: string | null; + transport_mode?: string | null = 'walking'; + created_at?: string | null; + updated_at?: string | null; + osm_id?: string | null; + route_geometry?: string | null; + route_color?: string | null; + stop_type?: string | null; + fill_percent?: number | null; + amap_poi_id?: string | null; + source?: string | null; + email?: string | null; + opening_hours?: string | null; + place_tags = new Collection(this); + budget_items_collection = new Collection(this); + dawarich_visit_suggestions_collection = new Collection(this); + day_accommodations_collection = new Collection(this); + day_assignments_collection = new Collection(this); + file_links_collection = new Collection(this); + journey_entries_collection = new Collection(this); + photos_collection = new Collection(this); + place_ratings_collection = new Collection(this); + place_regions: Ref | null = null; + reservations_collection = new Collection(this); + roadtrip_day_tracks_collection = new Collection(this); + trip_files_collection = new Collection(this); +} + +export const PlacesSchema = defineEntity({ + class: Places, + repository: () => PlacesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_places_trip_id'), + trip_id: p.integer().persist(false).index('idx_places_trip_id'), + name: p.text(), + description: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + address: p.text().nullable(), + category: () => p.manyToOne(Categories).ref().nullable().hidden().index('idx_places_category_id'), + category_id: p.integer().nullable().persist(false).index('idx_places_category_id'), + price: p.double().nullable(), + currency: p.text().nullable(), + reservation_status: p.text().nullable(), + reservation_notes: p.text().nullable(), + reservation_datetime: p.text().nullable(), + place_time: p.text().nullable(), + end_time: p.text().nullable(), + duration_minutes: p.integer().nullable(), + notes: p.text().nullable(), + image_url: p.text().nullable(), + google_place_id: p.text().nullable(), + google_ftid: p.text().nullable(), + website: p.text().nullable(), + phone: p.text().nullable(), + transport_mode: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + osm_id: p.text().nullable(), + route_geometry: p.text().nullable(), + route_color: p.text().nullable(), + stop_type: p.text().nullable(), + fill_percent: p.integer().nullable(), + amap_poi_id: p.text().nullable(), + source: p.text().nullable(), + email: p.text().nullable(), + opening_hours: p.text().nullable(), + place_tags: () => p.manyToMany(Tags).pivotTable('place_tags').joinColumn('place_id').inverseJoinColumn('tag_id').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('place').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedPlace').hidden(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('place').hidden(), + day_assignments_collection: () => p.oneToMany(DayAssignments).mappedBy('place').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('place').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('sourcePlace').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('place').hidden(), + place_ratings_collection: () => p.oneToMany(PlaceRatings).mappedBy('place').hidden(), + place_regions: () => p.oneToOne(PlaceRegions).ref().mappedBy('place').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(), + roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(), + }, +}); diff --git a/server/src/db/entities/PluginActions.entity.ts b/server/src/db/entities/PluginActions.entity.ts new file mode 100644 index 000000000..84caeece3 --- /dev/null +++ b/server/src/db/entities/PluginActions.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginActionsRepository } from '../repositories/PluginActions.repository'; + +export class PluginActions { + [EntityRepositoryType]?: PluginActionsRepository; + [PrimaryKeyProp]?: ['plugin_id', 'action_key']; + plugin_id!: string; + action_key!: string; + label!: string; + hint?: string | null; + danger: number & Opt = 0; + sort_order: number & Opt = 0; + scope: string & Opt = 'user'; +} + +export const PluginActionsSchema = defineEntity({ + class: PluginActions, + repository: () => PluginActionsRepository, + properties: { + plugin_id: p.text().primary(), + action_key: p.text().primary(), + label: p.text(), + hint: p.text().nullable(), + danger: p.integer().default(0), + sort_order: p.integer().default(0), + scope: p.text().default('user'), + }, +}); diff --git a/server/src/db/entities/PluginCapabilityAudit.entity.ts b/server/src/db/entities/PluginCapabilityAudit.entity.ts new file mode 100644 index 000000000..10c034cb2 --- /dev/null +++ b/server/src/db/entities/PluginCapabilityAudit.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginCapabilityAuditRepository } from '../repositories/PluginCapabilityAudit.repository'; + +export class PluginCapabilityAudit { + [EntityRepositoryType]?: PluginCapabilityAuditRepository; + id!: number & Opt; + plugin_id!: string; + acting_user_id?: number | null; + method!: string; + resource?: string | null; + code!: string; + ts!: string & Opt; + prev_hash?: string | null; + hash!: string; +} + +export const PluginCapabilityAuditSchema = defineEntity({ + class: PluginCapabilityAudit, + repository: () => PluginCapabilityAuditRepository, + indexes: [{ name: 'idx_plugin_audit_plugin', properties: ['plugin_id', 'id'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + acting_user_id: p.integer().nullable(), + method: p.text(), + resource: p.text().nullable(), + code: p.text(), + ts: p.text().defaultRaw(`(datetime('now'))`), + prev_hash: p.text().nullable(), + hash: p.text(), + }, +}); diff --git a/server/src/db/entities/PluginEgressHosts.entity.ts b/server/src/db/entities/PluginEgressHosts.entity.ts new file mode 100644 index 000000000..e5b6398a7 --- /dev/null +++ b/server/src/db/entities/PluginEgressHosts.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginEgressHostsRepository } from '../repositories/PluginEgressHosts.repository'; + +export class PluginEgressHosts { + [EntityRepositoryType]?: PluginEgressHostsRepository; + id!: number & Opt; + plugin_id!: string; + host!: string; + created_at!: string & Opt; +} + +export const PluginEgressHostsSchema = defineEntity({ + class: PluginEgressHosts, + repository: () => PluginEgressHostsRepository, + uniques: [{ properties: ['plugin_id', 'host'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text().index('idx_plugin_egress_hosts_plugin'), + host: p.text(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginEntityMetadata.entity.ts b/server/src/db/entities/PluginEntityMetadata.entity.ts new file mode 100644 index 000000000..818bf41e3 --- /dev/null +++ b/server/src/db/entities/PluginEntityMetadata.entity.ts @@ -0,0 +1,34 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginEntityMetadataRepository } from '../repositories/PluginEntityMetadata.repository'; + +export class PluginEntityMetadata { + [EntityRepositoryType]?: PluginEntityMetadataRepository; + id!: number & Opt; + plugin_id!: string; + entity_type!: string; + entity_id!: number; + key!: string; + value?: string | null; + updated_at!: string & Opt; +} + +export const PluginEntityMetadataSchema = defineEntity({ + class: PluginEntityMetadata, + repository: () => PluginEntityMetadataRepository, + indexes: [ + { + name: 'idx_plugin_meta_entity', + properties: ['plugin_id', 'entity_type', 'entity_id'], + }, + ], + uniques: [{ properties: ['plugin_id', 'entity_type', 'entity_id', 'key'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + entity_type: p.text(), + entity_id: p.integer(), + key: p.text(), + value: p.text().nullable(), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginErrorLog.entity.ts b/server/src/db/entities/PluginErrorLog.entity.ts new file mode 100644 index 000000000..e3b8f8143 --- /dev/null +++ b/server/src/db/entities/PluginErrorLog.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginErrorLogRepository } from '../repositories/PluginErrorLog.repository'; +import { DbTimestampType } from '../types'; + +export class PluginErrorLog { + [EntityRepositoryType]?: PluginErrorLogRepository; + id!: number & Opt; + plugin_id!: string; + ts?: string | null; + level: string & Opt = 'error'; + message?: string | null; + stack?: string | null; +} + +export const PluginErrorLogSchema = defineEntity({ + class: PluginErrorLog, + repository: () => PluginErrorLogRepository, + indexes: [ + { name: 'idx_plugin_error_log_plugin', properties: ['plugin_id', 'ts'] }, + ], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + ts: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + level: p.text().default('error'), + message: p.text().nullable(), + stack: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PluginMetaMigrations.entity.ts b/server/src/db/entities/PluginMetaMigrations.entity.ts new file mode 100644 index 000000000..48fffae11 --- /dev/null +++ b/server/src/db/entities/PluginMetaMigrations.entity.ts @@ -0,0 +1,21 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginMetaMigrationsRepository } from '../repositories/PluginMetaMigrations.repository'; +import { DbTimestampType } from '../types'; + +export class PluginMetaMigrations { + [EntityRepositoryType]?: PluginMetaMigrationsRepository; + [PrimaryKeyProp]?: ['plugin_id', 'migration_id']; + plugin_id!: string; + migration_id!: string; + applied_at?: string | null; +} + +export const PluginMetaMigrationsSchema = defineEntity({ + class: PluginMetaMigrations, + repository: () => PluginMetaMigrationsRepository, + properties: { + plugin_id: p.text().primary(), + migration_id: p.text().primary(), + applied_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PluginOauthState.entity.ts b/server/src/db/entities/PluginOauthState.entity.ts new file mode 100644 index 000000000..2893eda14 --- /dev/null +++ b/server/src/db/entities/PluginOauthState.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginOauthStateRepository } from '../repositories/PluginOauthState.repository'; + +export class PluginOauthState { + [EntityRepositoryType]?: PluginOauthStateRepository; + [PrimaryKeyProp]?: 'state'; + state?: string | null; + plugin_id!: string; + user_id!: number; + verifier!: string; + created_at!: number; +} + +export const PluginOauthStateSchema = defineEntity({ + class: PluginOauthState, + repository: () => PluginOauthStateRepository, + properties: { + state: p.text().primary().nullable(), + plugin_id: p.text(), + user_id: p.integer(), + verifier: p.text(), + created_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/PluginOauthTokens.entity.ts b/server/src/db/entities/PluginOauthTokens.entity.ts new file mode 100644 index 000000000..14108533c --- /dev/null +++ b/server/src/db/entities/PluginOauthTokens.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginOauthTokensRepository } from '../repositories/PluginOauthTokens.repository'; + +export class PluginOauthTokens { + [EntityRepositoryType]?: PluginOauthTokensRepository; + [PrimaryKeyProp]?: ['plugin_id', 'user_id']; + plugin_id!: string; + user_id!: number; + access_token?: string | null; + refresh_token?: string | null; + expires_at?: number | null; + scope?: string | null; + updated_at!: string & Opt; +} + +export const PluginOauthTokensSchema = defineEntity({ + class: PluginOauthTokens, + repository: () => PluginOauthTokensRepository, + properties: { + plugin_id: p.text().primary(), + user_id: p.integer().primary(), + access_token: p.text().nullable(), + refresh_token: p.text().nullable(), + expires_at: p.integer().nullable(), + scope: p.text().nullable(), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginScheduledTasks.entity.ts b/server/src/db/entities/PluginScheduledTasks.entity.ts new file mode 100644 index 000000000..9597eed7e --- /dev/null +++ b/server/src/db/entities/PluginScheduledTasks.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginScheduledTasksRepository } from '../repositories/PluginScheduledTasks.repository'; + +export class PluginScheduledTasks { + [EntityRepositoryType]?: PluginScheduledTasksRepository; + id!: number & Opt; + plugin_id!: string; + name!: string; + due_at!: number; + payload: string & Opt = 'null'; + every_ms?: number | null; + created_at!: string & Opt; +} + +export const PluginScheduledTasksSchema = defineEntity({ + class: PluginScheduledTasks, + repository: () => PluginScheduledTasksRepository, + uniques: [{ properties: ['plugin_id', 'name'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + name: p.text(), + due_at: p.integer().index('idx_plugin_sched_due'), + payload: p.text().default('null'), + every_ms: p.integer().nullable(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginSettingsFields.entity.ts b/server/src/db/entities/PluginSettingsFields.entity.ts new file mode 100644 index 000000000..0100d4515 --- /dev/null +++ b/server/src/db/entities/PluginSettingsFields.entity.ts @@ -0,0 +1,42 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginSettingsFieldsRepository } from '../repositories/PluginSettingsFields.repository'; + +export class PluginSettingsFields { + [EntityRepositoryType]?: PluginSettingsFieldsRepository; + id!: number & Opt; + plugin_id!: string; + field_key!: string; + label?: string | null; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required: number & Opt = 0; + secret: number & Opt = 0; + scope: string & Opt = 'instance'; + options?: string | null; + oauth_config?: string | null; + sort_order: number & Opt = 0; + default_value?: string | null; +} + +export const PluginSettingsFieldsSchema = defineEntity({ + class: PluginSettingsFields, + repository: () => PluginSettingsFieldsRepository, + uniques: [{ properties: ['plugin_id', 'field_key'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + field_key: p.text(), + label: p.text().nullable(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().default(0), + secret: p.integer().default(0), + scope: p.text().default('instance'), + options: p.text().nullable(), + oauth_config: p.text().nullable(), + sort_order: p.integer().default(0), + default_value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PluginUserConfig.entity.ts b/server/src/db/entities/PluginUserConfig.entity.ts new file mode 100644 index 000000000..b6ae59582 --- /dev/null +++ b/server/src/db/entities/PluginUserConfig.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginUserConfigRepository } from '../repositories/PluginUserConfig.repository'; + +export class PluginUserConfig { + [EntityRepositoryType]?: PluginUserConfigRepository; + [PrimaryKeyProp]?: ['plugin_id', 'user_id']; + plugin_id!: string; + user_id!: number; + config: string & Opt = '{}'; + updated_at!: string & Opt; +} + +export const PluginUserConfigSchema = defineEntity({ + class: PluginUserConfig, + repository: () => PluginUserConfigRepository, + properties: { + plugin_id: p.text().primary(), + user_id: p.integer().primary(), + config: p.text().default('{}'), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginUserErasureQueue.entity.ts b/server/src/db/entities/PluginUserErasureQueue.entity.ts new file mode 100644 index 000000000..585b5eb70 --- /dev/null +++ b/server/src/db/entities/PluginUserErasureQueue.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginUserErasureQueueRepository } from '../repositories/PluginUserErasureQueue.repository'; + +export class PluginUserErasureQueue { + [EntityRepositoryType]?: PluginUserErasureQueueRepository; + id!: number & Opt; + plugin_id!: string; + user_id!: number; + created_at!: string & Opt; +} + +export const PluginUserErasureQueueSchema = defineEntity({ + class: PluginUserErasureQueue, + repository: () => PluginUserErasureQueueRepository, + uniques: [{ properties: ['plugin_id', 'user_id'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text().index('idx_plugin_erasure_plugin'), + user_id: p.integer(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/Plugins.entity.ts b/server/src/db/entities/Plugins.entity.ts new file mode 100644 index 000000000..53d060d15 --- /dev/null +++ b/server/src/db/entities/Plugins.entity.ts @@ -0,0 +1,76 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginsRepository } from '../repositories/Plugins.repository'; +import { DbTimestampType } from '../types'; + +export class Plugins { + [EntityRepositoryType]?: PluginsRepository; + id?: string | null; + name!: string; + description?: string | null; + type: string & Opt = 'integration'; + icon?: string | null = 'Blocks'; + version?: string | null; + api_version?: number | null = 1; + min_trek_version?: string | null; + permissions?: string | null = '[]'; + granted_permissions?: string | null = '[]'; + status: string & Opt = 'inactive'; + config?: string | null = '{}'; + source_repo?: string | null; + source_commit?: string | null; + sha256?: string | null; + crash_count: number & Opt = 0; + last_error?: string | null; + reviewed_at?: string | null; + sort_order: number & Opt = 0; + installed_at?: string | null; + updated_at?: string | null; + enabled: number & Opt = 0; + capabilities: string & Opt = '{}'; + author_pubkey?: string | null; + dependencies: string & Opt = '{}'; + operator_egress: number & Opt = 0; + update_block_code?: string | null; + update_block_detail?: string | null; + update_block_version?: string | null; + trek_range?: string | null; + update_hold: number & Opt = 0; +} + +export const PluginsSchema = defineEntity({ + class: Plugins, + repository: () => PluginsRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + type: p.text().default('integration'), + icon: p.text().nullable(), + version: p.text().nullable(), + api_version: p.integer().nullable(), + min_trek_version: p.text().nullable(), + permissions: p.text().nullable(), + granted_permissions: p.text().nullable(), + status: p.text().default('inactive'), + config: p.text().nullable(), + source_repo: p.text().nullable(), + source_commit: p.text().nullable(), + sha256: p.text().nullable(), + crash_count: p.integer().default(0), + last_error: p.text().nullable(), + reviewed_at: p.text().nullable(), + sort_order: p.integer().default(0), + installed_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + enabled: p.integer().default(0), + capabilities: p.text().default('{}'), + author_pubkey: p.text().nullable(), + dependencies: p.text().default('{}'), + operator_egress: p.integer().default(0), + update_block_code: p.text().nullable(), + update_block_detail: p.text().nullable(), + update_block_version: p.text().nullable(), + trek_range: p.text().nullable(), + update_hold: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/PushSubscriptions.entity.ts b/server/src/db/entities/PushSubscriptions.entity.ts new file mode 100644 index 000000000..e346ae97d --- /dev/null +++ b/server/src/db/entities/PushSubscriptions.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PushSubscriptionsRepository } from '../repositories/PushSubscriptions.repository'; +import { Users } from './Users.entity'; + +export class PushSubscriptions { + [EntityRepositoryType]?: PushSubscriptionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + endpoint!: string; + p256dh!: string; + auth!: string; + vapid_public_key!: string; + user_agent?: string | null; + created_at!: string & Opt; + last_success_at?: string | null; + failure_count: number & Opt = 0; +} + +export const PushSubscriptionsSchema = defineEntity({ + class: PushSubscriptions, + repository: () => PushSubscriptionsRepository, + uniques: [{ properties: ['endpoint'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_push_subscriptions_user'), + user_id: p.integer().persist(false).index('idx_push_subscriptions_user'), + endpoint: p.text(), + p256dh: p.text(), + auth: p.text(), + vapid_public_key: p.text(), + user_agent: p.text().nullable(), + created_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_success_at: p.text().nullable(), + failure_count: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/ReservationDayPositions.entity.ts b/server/src/db/entities/ReservationDayPositions.entity.ts new file mode 100644 index 000000000..5dddf729e --- /dev/null +++ b/server/src/db/entities/ReservationDayPositions.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationDayPositionsRepository } from '../repositories/ReservationDayPositions.repository'; +import { Days } from './Days.entity'; +import { Reservations } from './Reservations.entity'; + +export class ReservationDayPositions { + [EntityRepositoryType]?: ReservationDayPositionsRepository; + [PrimaryKeyProp]?: ['reservation', 'day']; + reservation!: Ref; + reservation_id!: number; + day!: Ref; + day_id!: number; + position!: number; +} + +export const ReservationDayPositionsSchema = defineEntity({ + class: ReservationDayPositions, + repository: () => ReservationDayPositionsRepository, + uniques: [{ properties: ['reservation', 'day'] }], + properties: { + reservation: () => p.manyToOne(Reservations).primary().ref().hidden(), + reservation_id: p.integer().persist(false), + day: () => p.manyToOne(Days).primary().ref().hidden(), + day_id: p.integer().persist(false), + position: p.double(), + }, +}); diff --git a/server/src/db/entities/ReservationEndpoints.entity.ts b/server/src/db/entities/ReservationEndpoints.entity.ts new file mode 100644 index 000000000..825307730 --- /dev/null +++ b/server/src/db/entities/ReservationEndpoints.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationEndpointsRepository } from '../repositories/ReservationEndpoints.repository'; +import { DbTimestampType } from '../types'; +import { Reservations } from './Reservations.entity'; + +export class ReservationEndpoints { + [EntityRepositoryType]?: ReservationEndpointsRepository; + id!: number & Opt; + reservation!: Ref; + reservation_id!: number; + role!: string; + sequence: number & Opt = 0; + name!: string; + code?: string | null; + lat!: number; + lng!: number; + timezone?: string | null; + local_time?: string | null; + local_date?: string | null; + created_at?: string | null; +} + +export const ReservationEndpointsSchema = defineEntity({ + class: ReservationEndpoints, + repository: () => ReservationEndpointsRepository, + properties: { + id: p.integer().primary(), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').hidden().index('idx_reservation_endpoints_reservation_id'), + reservation_id: p.integer().persist(false).index('idx_reservation_endpoints_reservation_id'), + role: p.text(), + sequence: p.integer().default(0), + name: p.text(), + code: p.text().nullable(), + lat: p.double(), + lng: p.double(), + timezone: p.text().nullable(), + local_time: p.text().nullable(), + local_date: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/ReservationTravelers.entity.ts b/server/src/db/entities/ReservationTravelers.entity.ts new file mode 100644 index 000000000..0ac8313e1 --- /dev/null +++ b/server/src/db/entities/ReservationTravelers.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationTravelersRepository } from '../repositories/ReservationTravelers.repository'; +import { Reservations } from './Reservations.entity'; +import { Users } from './Users.entity'; + +export class ReservationTravelers { + [EntityRepositoryType]?: ReservationTravelersRepository; + id!: number & Opt; + reservation!: Ref; + reservation_id!: number; + user!: Ref; + user_id!: number; +} + +export const ReservationTravelersSchema = defineEntity({ + class: ReservationTravelers, + repository: () => ReservationTravelersRepository, + uniques: [{ properties: ['reservation', 'user'] }], + properties: { + id: p.integer().primary(), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').hidden().index('idx_reservation_travelers_res'), + reservation_id: p.integer().persist(false).index('idx_reservation_travelers_res'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_reservation_travelers_user'), + user_id: p.integer().persist(false).index('idx_reservation_travelers_user'), + }, +}); diff --git a/server/src/db/entities/Reservations.entity.ts b/server/src/db/entities/Reservations.entity.ts new file mode 100644 index 000000000..8c946b908 --- /dev/null +++ b/server/src/db/entities/Reservations.entity.ts @@ -0,0 +1,106 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationsRepository } from '../repositories/Reservations.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { Days } from './Days.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { ReservationDayPositions } from './ReservationDayPositions.entity'; +import { ReservationEndpoints } from './ReservationEndpoints.entity'; +import { ReservationTravelers } from './ReservationTravelers.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class Reservations { + [EntityRepositoryType]?: ReservationsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day?: Ref | null; + day_id?: number | null; + endDay?: Ref | null; + end_day_id?: number | null; + place?: Ref | null; + place_id?: number | null; + assignment?: Ref | null; + assignment_id?: number | null; + title!: string; + accommodation_id?: string | null; + reservation_time?: string | null; + reservation_end_time?: string | null; + location?: string | null; + confirmation_number?: string | null; + notes?: string | null; + status?: string | null = 'pending'; + type?: string | null = 'other'; + created_at?: string | null; + metadata?: string | null; + day_plan_position?: number | null; + needs_review: number & Opt = 0; + external_source?: string | null; + external_id?: string | null; + external_owner_user_id?: number | null; + external_synced_at?: string | null; + sync_enabled?: number | null = 1; + external_hash?: string | null; + url?: string | null; + ingest_state: string & Opt = 'live'; + budget_items_collection = new Collection(this); + file_links_collection = new Collection(this); + reservation_day_positions_collection = new Collection(this); + reservation_endpoints_collection = new Collection(this); + reservation_travelers_collection = new Collection(this); + trip_files_collection = new Collection(this); +} + +export const ReservationsSchema = defineEntity({ + class: Reservations, + repository: () => ReservationsRepository, + uniques: [ + { + name: 'idx_reservations_external', + properties: ['external_source', 'external_id', 'trip_id'], + }, + ], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_reservations_trip_id'), + trip_id: p.integer().persist(false).index('idx_reservations_trip_id'), + day: () => p.manyToOne(Days).ref().nullable().hidden().index('idx_reservations_day_id'), + day_id: p.integer().nullable().persist(false).index('idx_reservations_day_id'), + endDay: () => p.manyToOne(Days).ref().nullable().hidden(), + end_day_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + assignment: () => p.manyToOne(DayAssignments).ref().nullable().hidden(), + assignment_id: p.integer().nullable().persist(false), + title: p.text(), + accommodation_id: p.text().nullable(), + reservation_time: p.text().nullable(), + reservation_end_time: p.text().nullable(), + location: p.text().nullable(), + confirmation_number: p.text().nullable(), + notes: p.text().nullable(), + status: p.text().nullable(), + type: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + metadata: p.text().nullable(), + day_plan_position: p.double().nullable().defaultRaw(`NULL`), + needs_review: p.integer().default(0), + external_source: p.text().nullable(), + external_id: p.text().nullable(), + external_owner_user_id: p.integer().nullable(), + external_synced_at: p.text().nullable(), + sync_enabled: p.integer().nullable(), + external_hash: p.text().nullable(), + url: p.text().nullable(), + ingest_state: p.text().default('live'), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('reservation').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('reservation').hidden(), + reservation_day_positions_collection: () => p.oneToMany(ReservationDayPositions).mappedBy('reservation').hidden(), + reservation_endpoints_collection: () => p.oneToMany(ReservationEndpoints).mappedBy('reservation').hidden(), + reservation_travelers_collection: () => p.oneToMany(ReservationTravelers).mappedBy('reservation').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('reservation').hidden(), + }, +}); diff --git a/server/src/db/entities/RoadtripDayBoundaries.entity.ts b/server/src/db/entities/RoadtripDayBoundaries.entity.ts new file mode 100644 index 000000000..1edce45ea --- /dev/null +++ b/server/src/db/entities/RoadtripDayBoundaries.entity.ts @@ -0,0 +1,43 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripDayBoundariesRepository } from '../repositories/RoadtripDayBoundaries.repository'; +import { DayAssignments } from './DayAssignments.entity'; +import { Trips } from './Trips.entity'; + +export class RoadtripDayBoundaries { + [EntityRepositoryType]?: RoadtripDayBoundariesRepository; + [PrimaryKeyProp]?: ['trip', 'day_number']; + trip!: Ref; + trip_id!: number; + day_number!: number; + fromAssignment!: Ref; + from_assignment_id!: number; + toAssignment?: Ref | null; + to_assignment_id?: number | null; + fraction!: number; +} + +export const RoadtripDayBoundariesSchema = defineEntity({ + class: RoadtripDayBoundaries, + repository: () => RoadtripDayBoundariesRepository, + uniques: [{ properties: ['trip', 'day_number'] }], + checks: [ + { + name: 'roadtrip_day_boundaries_fraction_check', + expression: 'fraction BETWEEN 0 AND 1', + }, + { + name: 'roadtrip_day_boundaries_day_number_check', + expression: 'day_number >= 1', + }, + ], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + day_number: p.integer().primary(), + fromAssignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').hidden(), + from_assignment_id: p.integer().persist(false), + toAssignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').nullable().hidden(), + to_assignment_id: p.integer().nullable().persist(false), + fraction: p.double(), + }, +}); diff --git a/server/src/db/entities/RoadtripDayTracks.entity.ts b/server/src/db/entities/RoadtripDayTracks.entity.ts new file mode 100644 index 000000000..7c0387499 --- /dev/null +++ b/server/src/db/entities/RoadtripDayTracks.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripDayTracksRepository } from '../repositories/RoadtripDayTracks.repository'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; + +export class RoadtripDayTracks { + [EntityRepositoryType]?: RoadtripDayTracksRepository; + [PrimaryKeyProp]?: 'day'; + day?: Ref | null; + day_id?: number | null; + place!: Ref; + place_id!: number; + stray_km?: number | null; + created_at?: string | null; +} + +export const RoadtripDayTracksSchema = defineEntity({ + class: RoadtripDayTracks, + repository: () => RoadtripDayTracksRepository, + properties: { + day: () => p.oneToOne(Days).primary().ref().deleteRule('cascade').nullable().hidden(), + day_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_roadtrip_day_tracks_place'), + place_id: p.integer().persist(false).index('idx_roadtrip_day_tracks_place'), + stray_km: p.double().nullable(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/RoadtripPreferences.entity.ts b/server/src/db/entities/RoadtripPreferences.entity.ts new file mode 100644 index 000000000..d5879c332 --- /dev/null +++ b/server/src/db/entities/RoadtripPreferences.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripPreferencesRepository } from '../repositories/RoadtripPreferences.repository'; +import { Trips } from './Trips.entity'; + +export class RoadtripPreferences { + [EntityRepositoryType]?: RoadtripPreferencesRepository; + [PrimaryKeyProp]?: ['trip', 'key']; + trip!: Ref; + trip_id!: number; + key!: string; + value!: string; +} + +export const RoadtripPreferencesSchema = defineEntity({ + class: RoadtripPreferences, + repository: () => RoadtripPreferencesRepository, + uniques: [{ properties: ['trip', 'key'] }], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + key: p.text().primary(), + value: p.text(), + }, +}); diff --git a/server/src/db/entities/RoadtripVias.entity.ts b/server/src/db/entities/RoadtripVias.entity.ts new file mode 100644 index 000000000..477a07124 --- /dev/null +++ b/server/src/db/entities/RoadtripVias.entity.ts @@ -0,0 +1,36 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripViasRepository } from '../repositories/RoadtripVias.repository'; +import { Days } from './Days.entity'; + +export class RoadtripVias { + [EntityRepositoryType]?: RoadtripViasRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + after_order_index!: number; + sequence: number & Opt = 0; + lat!: number; + lng!: number; + created_at?: string | null; +} + +export const RoadtripViasSchema = defineEntity({ + class: RoadtripVias, + repository: () => RoadtripViasRepository, + indexes: [ + { + name: 'idx_roadtrip_vias_day', + properties: ['day_id', 'after_order_index', 'sequence'], + }, + ], + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden(), + day_id: p.integer().persist(false), + after_order_index: p.integer(), + sequence: p.integer().default(0), + lat: p.double(), + lng: p.double(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/RouteUsageDaily.entity.ts b/server/src/db/entities/RouteUsageDaily.entity.ts new file mode 100644 index 000000000..fea8f7edc --- /dev/null +++ b/server/src/db/entities/RouteUsageDaily.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { RouteUsageDailyRepository } from '../repositories/RouteUsageDaily.repository'; + +export class RouteUsageDaily { + [EntityRepositoryType]?: RouteUsageDailyRepository; + [PrimaryKeyProp]?: ['day', 'profile', 'surface', 'self_hosted']; + day!: string; + profile!: string; + surface!: string; + self_hosted!: number; + requests: number & Opt = 0; + waypoints: number & Opt = 0; + km!: number & Opt; + failed: number & Opt = 0; +} + +export const RouteUsageDailySchema = defineEntity({ + class: RouteUsageDaily, + repository: () => RouteUsageDailyRepository, + properties: { + day: p.text().primary().index('idx_route_usage_day'), + profile: p.text().primary(), + surface: p.text().primary(), + self_hosted: p.integer().primary(), + requests: p.integer().default(0), + waypoints: p.integer().default(0), + km: p.double().defaultRaw(`0`), + failed: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/SchemaVersion.entity.ts b/server/src/db/entities/SchemaVersion.entity.ts new file mode 100644 index 000000000..48e8e4cc7 --- /dev/null +++ b/server/src/db/entities/SchemaVersion.entity.ts @@ -0,0 +1,17 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { SchemaVersionRepository } from '../repositories/SchemaVersion.repository'; + +export class SchemaVersion { + [EntityRepositoryType]?: SchemaVersionRepository; + id!: number & Opt; + version!: number; +} + +export const SchemaVersionSchema = defineEntity({ + class: SchemaVersion, + repository: () => SchemaVersionRepository, + properties: { + id: p.integer().primary(), + version: p.integer(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayCountries.entity.ts b/server/src/db/entities/SchoolHolidayCountries.entity.ts new file mode 100644 index 000000000..73e041190 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayCountries.entity.ts @@ -0,0 +1,21 @@ +import { Collection, EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayCountriesRepository } from '../repositories/SchoolHolidayCountries.repository'; +import { SchoolHolidayRegions } from './SchoolHolidayRegions.entity'; + +export class SchoolHolidayCountries { + [EntityRepositoryType]?: SchoolHolidayCountriesRepository; + [PrimaryKeyProp]?: 'code'; + code?: string | null; + name!: string; + school_holiday_regions_collection = new Collection(this); +} + +export const SchoolHolidayCountriesSchema = defineEntity({ + class: SchoolHolidayCountries, + repository: () => SchoolHolidayCountriesRepository, + properties: { + code: p.text().primary().nullable(), + name: p.text(), + school_holiday_regions_collection: () => p.oneToMany(SchoolHolidayRegions).mappedBy('countryRef').hidden(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayPeriods.entity.ts b/server/src/db/entities/SchoolHolidayPeriods.entity.ts new file mode 100644 index 000000000..628ec39e7 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayPeriods.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayPeriodsRepository } from '../repositories/SchoolHolidayPeriods.repository'; +import { SchoolHolidayRegions } from './SchoolHolidayRegions.entity'; + +export class SchoolHolidayPeriods { + [EntityRepositoryType]?: SchoolHolidayPeriodsRepository; + id!: number & Opt; + region!: Ref; + region_id!: number; + name!: string; + start_date!: string; + end_date!: string; +} + +export const SchoolHolidayPeriodsSchema = defineEntity({ + class: SchoolHolidayPeriods, + repository: () => SchoolHolidayPeriodsRepository, + checks: [ + { + name: 'school_holiday_periods_end_date_check', + expression: 'end_date >= start_date', + }, + ], + properties: { + id: p.integer().primary(), + region: () => p.manyToOne(SchoolHolidayRegions).ref().deleteRule('cascade').hidden().index('idx_school_holiday_periods_region'), + region_id: p.integer().persist(false).index('idx_school_holiday_periods_region'), + name: p.text(), + start_date: p.text(), + end_date: p.text(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayRegions.entity.ts b/server/src/db/entities/SchoolHolidayRegions.entity.ts new file mode 100644 index 000000000..154fbc607 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayRegions.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayRegionsRepository } from '../repositories/SchoolHolidayRegions.repository'; +import { SchoolHolidayCountries } from './SchoolHolidayCountries.entity'; +import { SchoolHolidayPeriods } from './SchoolHolidayPeriods.entity'; + +export class SchoolHolidayRegions { + [EntityRepositoryType]?: SchoolHolidayRegionsRepository; + id!: number & Opt; + country!: string; + name!: string; + revision: number & Opt = 1; + countryRef!: Ref; + school_holiday_periods_collection = new Collection(this); +} + +export const SchoolHolidayRegionsSchema = defineEntity({ + class: SchoolHolidayRegions, + repository: () => SchoolHolidayRegionsRepository, + uniques: [{ properties: ['countryRef', 'name'] }], + properties: { + id: p.integer().primary(), + country: p.text().persist(false), + name: p.text().collation('NOCASE'), + revision: p.integer().default(1), + countryRef: () => p.manyToOne(SchoolHolidayCountries).ref().joinColumn('country').hidden(), + school_holiday_periods_collection: () => p.oneToMany(SchoolHolidayPeriods).mappedBy('region').hidden(), + }, +}); diff --git a/server/src/db/entities/Settings.entity.ts b/server/src/db/entities/Settings.entity.ts new file mode 100644 index 000000000..6de914911 --- /dev/null +++ b/server/src/db/entities/Settings.entity.ts @@ -0,0 +1,25 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SettingsRepository } from '../repositories/Settings.repository'; +import { Users } from './Users.entity'; + +export class Settings { + [EntityRepositoryType]?: SettingsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + key!: string; + value?: string | null; +} + +export const SettingsSchema = defineEntity({ + class: Settings, + repository: () => SettingsRepository, + uniques: [{ properties: ['user', 'key'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + key: p.text(), + value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/ShareTokens.entity.ts b/server/src/db/entities/ShareTokens.entity.ts new file mode 100644 index 000000000..2ac1b161e --- /dev/null +++ b/server/src/db/entities/ShareTokens.entity.ts @@ -0,0 +1,47 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ShareTokensRepository } from '../repositories/ShareTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class ShareTokens { + [EntityRepositoryType]?: ShareTokensRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + token!: string; + created_by!: number; + share_map?: number | null = 1; + share_bookings?: number | null = 1; + share_packing?: number | null = 0; + share_budget?: number | null = 0; + share_collab?: number | null = 0; + created_at?: string | null; + expires_at?: string | null; + share_travel_only: number & Opt = 0; + share_hide_images: number & Opt = 0; + createdByRef!: Ref; +} + +export const ShareTokensSchema = defineEntity({ + class: ShareTokens, + repository: () => ShareTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + token: p.text().index('idx_share_tokens_token'), + created_by: p.integer().persist(false), + share_map: p.integer().nullable(), + share_bookings: p.integer().nullable(), + share_packing: p.integer().nullable(), + share_budget: p.integer().nullable(), + share_collab: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + expires_at: p.text().nullable(), + share_travel_only: p.integer().default(0), + share_hide_images: p.integer().default(0), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').hidden(), + }, +}); diff --git a/server/src/db/entities/Tags.entity.ts b/server/src/db/entities/Tags.entity.ts new file mode 100644 index 000000000..7c58f8e7e --- /dev/null +++ b/server/src/db/entities/Tags.entity.ts @@ -0,0 +1,33 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TagsRepository } from '../repositories/Tags.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class Tags { + [EntityRepositoryType]?: TagsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + color?: string | null = '#10b981'; + created_at?: string | null; + collection_place_tags_inverse = new Collection(this); + place_tags_inverse = new Collection(this); +} + +export const TagsSchema = defineEntity({ + class: Tags, + repository: () => TagsRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + color: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_place_tags_inverse: () => p.manyToMany(CollectionPlaces).mappedBy('collection_place_tags').hidden(), + place_tags_inverse: () => p.manyToMany(Places).mappedBy('place_tags').hidden(), + }, +}); diff --git a/server/src/db/entities/TodoCategoryAssignees.entity.ts b/server/src/db/entities/TodoCategoryAssignees.entity.ts new file mode 100644 index 000000000..76003274d --- /dev/null +++ b/server/src/db/entities/TodoCategoryAssignees.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TodoCategoryAssigneesRepository } from '../repositories/TodoCategoryAssignees.repository'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TodoCategoryAssignees { + [EntityRepositoryType]?: TodoCategoryAssigneesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category_name!: string; + user!: Ref; + user_id!: number; +} + +export const TodoCategoryAssigneesSchema = defineEntity({ + class: TodoCategoryAssignees, + repository: () => TodoCategoryAssigneesRepository, + uniques: [{ properties: ['trip', 'category_name', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category_name: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/TodoItems.entity.ts b/server/src/db/entities/TodoItems.entity.ts new file mode 100644 index 000000000..206b2331e --- /dev/null +++ b/server/src/db/entities/TodoItems.entity.ts @@ -0,0 +1,44 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TodoItemsRepository } from '../repositories/TodoItems.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TodoItems { + [EntityRepositoryType]?: TodoItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + checked?: number | null = 0; + category?: string | null; + sort_order?: number | null = 0; + due_date?: string | null; + description?: string | null; + assignedUser?: Ref | null; + assigned_user_id?: number | null; + priority?: number | null = 0; + created_at?: string | null; + reminded_at?: string | null; +} + +export const TodoItemsSchema = defineEntity({ + class: TodoItems, + repository: () => TodoItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_todo_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_todo_items_trip_id'), + name: p.text(), + checked: p.integer().nullable(), + category: p.text().nullable(), + sort_order: p.integer().nullable(), + due_date: p.text().nullable(), + description: p.text().nullable(), + assignedUser: () => p.manyToOne(Users).ref().nullable().hidden(), + assigned_user_id: p.integer().nullable().persist(false), + priority: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + reminded_at: p.type(DbTimestampType).nullable(), + }, +}); diff --git a/server/src/db/entities/TrekPhotoCacheMeta.entity.ts b/server/src/db/entities/TrekPhotoCacheMeta.entity.ts new file mode 100644 index 000000000..6907a3888 --- /dev/null +++ b/server/src/db/entities/TrekPhotoCacheMeta.entity.ts @@ -0,0 +1,20 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { TrekPhotoCacheMetaRepository } from '../repositories/TrekPhotoCacheMeta.repository'; + +export class TrekPhotoCacheMeta { + [EntityRepositoryType]?: TrekPhotoCacheMetaRepository; + [PrimaryKeyProp]?: 'cache_key'; + cache_key!: string; + content_type: string & Opt = 'image/jpeg'; + fetched_at!: number; +} + +export const TrekPhotoCacheMetaSchema = defineEntity({ + class: TrekPhotoCacheMeta, + repository: () => TrekPhotoCacheMetaRepository, + properties: { + cache_key: p.text().primary(), + content_type: p.text().default('image/jpeg'), + fetched_at: p.integer().index('idx_trek_photo_cache_meta_fetched_at'), + }, +}); diff --git a/server/src/db/entities/TrekPhotos.entity.ts b/server/src/db/entities/TrekPhotos.entity.ts new file mode 100644 index 000000000..6c2c969de --- /dev/null +++ b/server/src/db/entities/TrekPhotos.entity.ts @@ -0,0 +1,67 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TrekPhotosRepository } from '../repositories/TrekPhotos.repository'; +import { DbTimestampType } from '../types'; +import { JourneyPhotos } from './JourneyPhotos.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Users } from './Users.entity'; + +export class TrekPhotos { + [EntityRepositoryType]?: TrekPhotosRepository; + id!: number & Opt; + provider!: string; + asset_id?: string | null; + owner?: Ref | null; + owner_id?: number | null; + file_path?: string | null; + thumbnail_path?: string | null; + width?: number | null; + height?: number | null; + created_at?: string | null; + passphrase?: string | null; + media_type: string & Opt = 'image'; + duration_ms?: number | null; + taken_at?: string | null; + lat?: number | null; + lng?: number | null; + journey_photos_collection = new Collection(this); + trip_photos_collection = new Collection(this); +} + +export const TrekPhotosSchema = defineEntity({ + class: TrekPhotos, + repository: () => TrekPhotosRepository, + indexes: [ + { + name: 'idx_trek_photos_geo', + where: 'lat IS NOT NULL AND lng IS NOT NULL', + properties: ['lat', 'lng'], + }, + ], + uniques: [ + { + name: 'idx_trek_photos_provider_asset', + where: 'asset_id IS NOT NULL', + properties: ['provider', 'asset_id', 'owner_id'], + }, + ], + properties: { + id: p.integer().primary(), + provider: p.text(), + asset_id: p.text().nullable(), + owner: () => p.manyToOne(Users).ref().nullable().hidden().index('idx_trek_photos_owner'), + owner_id: p.integer().nullable().persist(false).index('idx_trek_photos_owner'), + file_path: p.text().nullable(), + thumbnail_path: p.text().nullable(), + width: p.integer().nullable(), + height: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + passphrase: p.text().nullable().defaultRaw(`NULL`), + media_type: p.text().default('image'), + duration_ms: p.integer().nullable(), + taken_at: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('photo').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('photo').hidden(), + }, +}); diff --git a/server/src/db/entities/TripAlbumLinks.entity.ts b/server/src/db/entities/TripAlbumLinks.entity.ts new file mode 100644 index 000000000..df41280c2 --- /dev/null +++ b/server/src/db/entities/TripAlbumLinks.entity.ts @@ -0,0 +1,44 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripAlbumLinksRepository } from '../repositories/TripAlbumLinks.repository'; +import { DbTimestampType } from '../types'; +import { TripPhotos } from './TripPhotos.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripAlbumLinks { + [EntityRepositoryType]?: TripAlbumLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + provider!: string; + album_id!: string; + album_name: string & Opt = ''; + sync_enabled: number & Opt = 1; + last_synced_at?: string | null; + created_at?: string | null; + passphrase?: string | null; + trip_photos_collection = new Collection(this); +} + +export const TripAlbumLinksSchema = defineEntity({ + class: TripAlbumLinks, + repository: () => TripAlbumLinksRepository, + uniques: [{ properties: ['trip', 'user', 'provider', 'album_id'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_album_links_trip'), + trip_id: p.integer().persist(false).index('idx_trip_album_links_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + provider: p.text(), + album_id: p.text(), + album_name: p.text().default(''), + sync_enabled: p.integer().default(1), + last_synced_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + passphrase: p.text().nullable().defaultRaw(`NULL`), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('albumLink').hidden(), + }, +}); diff --git a/server/src/db/entities/TripDocumentLinks.entity.ts b/server/src/db/entities/TripDocumentLinks.entity.ts new file mode 100644 index 000000000..c366ad487 --- /dev/null +++ b/server/src/db/entities/TripDocumentLinks.entity.ts @@ -0,0 +1,88 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripDocumentLinksRepository } from '../repositories/TripDocumentLinks.repository'; +import { DbTimestampType } from '../types'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripDocumentLinks { + [EntityRepositoryType]?: TripDocumentLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + connection!: Ref; + connection_id!: number; + provider_id!: string; + remote_scope_key!: string; + remote_root_id?: string | null; + remote_root_path?: string | null; + remote_label: string & Opt = ''; + direction: string & Opt = 'both'; + delete_policy: string & Opt = 'unlink'; + conflict_policy: string & Opt = 'manual'; + sync_enabled: number & Opt = 1; + webhook_token?: string | null; + webhook_secret?: string | null; + webhook_subscription_id?: string | null; + remote_cursor?: string | null; + last_sync_at?: string | null; + last_sync_state: string & Opt = 'never'; + last_sync_error?: string | null; + failure_count: number & Opt = 0; + next_attempt_at?: string | null; + created_by?: number | null; + created_at?: string | null; + updated_at?: string | null; + createdByRef?: Ref | null; + document_sync_items_collection = new Collection(this); +} + +export const TripDocumentLinksSchema = defineEntity({ + class: TripDocumentLinks, + repository: () => TripDocumentLinksRepository, + indexes: [ + { + name: 'idx_trip_document_links_due', + properties: ['sync_enabled', 'next_attempt_at'], + }, + ], + uniques: [ + { + name: 'idx_trip_document_links_token', + where: 'webhook_token IS NOT NULL', + properties: ['webhook_token'], + }, + { properties: ['trip', 'connection', 'remote_scope_key'] }, + ], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_document_links_trip'), + trip_id: p.integer().persist(false).index('idx_trip_document_links_trip'), + connection: () => p.manyToOne(DocumentConnections).ref().deleteRule('cascade').hidden(), + connection_id: p.integer().persist(false), + provider_id: p.text(), + remote_scope_key: p.text(), + remote_root_id: p.text().nullable(), + remote_root_path: p.text().nullable(), + remote_label: p.text().default(''), + direction: p.text().default('both'), + delete_policy: p.text().default('unlink'), + conflict_policy: p.text().default('manual'), + sync_enabled: p.integer().default(1), + webhook_token: p.text().nullable(), + webhook_secret: p.text().nullable(), + webhook_subscription_id: p.text().nullable(), + remote_cursor: p.text().nullable(), + last_sync_at: p.text().nullable(), + last_sync_state: p.text().default('never'), + last_sync_error: p.text().nullable(), + failure_count: p.integer().default(0), + next_attempt_at: p.text().nullable(), + created_by: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('link').hidden(), + }, +}); diff --git a/server/src/db/entities/TripFiles.entity.ts b/server/src/db/entities/TripFiles.entity.ts new file mode 100644 index 000000000..2a8493ce3 --- /dev/null +++ b/server/src/db/entities/TripFiles.entity.ts @@ -0,0 +1,68 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripFilesRepository } from '../repositories/TripFiles.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripFiles { + [EntityRepositoryType]?: TripFilesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + place?: Ref | null; + place_id?: number | null; + reservation?: Ref | null; + reservation_id?: number | null; + filename!: string; + original_name!: string; + file_size?: number | null; + mime_type?: string | null; + description?: string | null; + created_at?: string | null; + note?: Ref | null; + note_id?: number | null; + uploaded_by?: number | null; + starred?: number | null = 0; + deleted_at?: string | null; + message?: Ref | null; + message_id?: number | null; + uploadedByRef?: Ref | null; + document_sync_items_collection = new Collection(this); + file_links_collection = new Collection(this); +} + +export const TripFilesSchema = defineEntity({ + class: TripFiles, + repository: () => TripFilesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_files_trip_id'), + trip_id: p.integer().persist(false).index('idx_trip_files_trip_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + reservation: () => p.manyToOne(Reservations).ref().nullable().hidden(), + reservation_id: p.integer().nullable().persist(false), + filename: p.text(), + original_name: p.text(), + file_size: p.integer().nullable(), + mime_type: p.text().nullable(), + description: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + note: () => p.manyToOne(CollabNotes).ref().nullable().hidden(), + note_id: p.integer().nullable().persist(false), + uploaded_by: p.integer().nullable().persist(false), + starred: p.integer().nullable(), + deleted_at: p.text().nullable(), + message: () => p.manyToOne(CollabMessages).ref().deleteRule('cascade').nullable().hidden().index('idx_trip_files_message_id'), + message_id: p.integer().nullable().persist(false).index('idx_trip_files_message_id'), + uploadedByRef: () => p.manyToOne(Users).ref().joinColumn('uploaded_by').nullable().hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('file').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('file').hidden(), + }, +}); diff --git a/server/src/db/entities/TripInviteTokens.entity.ts b/server/src/db/entities/TripInviteTokens.entity.ts new file mode 100644 index 000000000..b669c2514 --- /dev/null +++ b/server/src/db/entities/TripInviteTokens.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripInviteTokensRepository } from '../repositories/TripInviteTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripInviteTokens { + [EntityRepositoryType]?: TripInviteTokensRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + token!: string; + created_by?: number | null; + expires_at?: string | null; + created_at?: string | null; + createdByRef?: Ref | null; +} + +export const TripInviteTokensSchema = defineEntity({ + class: TripInviteTokens, + repository: () => TripInviteTokensRepository, + uniques: [{ properties: ['token'] }, { properties: ['trip'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + token: p.text().index('idx_trip_invite_tokens_token'), + created_by: p.integer().nullable().persist(false), + expires_at: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/TripMembers.entity.ts b/server/src/db/entities/TripMembers.entity.ts new file mode 100644 index 000000000..a08f68207 --- /dev/null +++ b/server/src/db/entities/TripMembers.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripMembersRepository } from '../repositories/TripMembers.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripMembers { + [EntityRepositoryType]?: TripMembersRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + invited_by?: number | null; + added_at?: string | null; + invitedByRef?: Ref | null; +} + +export const TripMembersSchema = defineEntity({ + class: TripMembers, + repository: () => TripMembersRepository, + uniques: [{ properties: ['trip', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_members_trip_id'), + trip_id: p.integer().persist(false).index('idx_trip_members_trip_id'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_trip_members_user_id'), + user_id: p.integer().persist(false).index('idx_trip_members_user_id'), + invited_by: p.integer().nullable().persist(false), + added_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + invitedByRef: () => p.manyToOne(Users).ref().joinColumn('invited_by').deleteRule('no action').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/TripPhotos.entity.ts b/server/src/db/entities/TripPhotos.entity.ts new file mode 100644 index 000000000..25e6384d9 --- /dev/null +++ b/server/src/db/entities/TripPhotos.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripPhotosRepository } from '../repositories/TripPhotos.repository'; +import { DbTimestampType } from '../types'; +import { TrekPhotos } from './TrekPhotos.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripPhotos { + [EntityRepositoryType]?: TripPhotosRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + photo!: Ref; + photo_id!: number; + shared: number & Opt = 1; + albumLink?: Ref | null; + album_link_id?: number | null; + added_at?: string | null; +} + +export const TripPhotosSchema = defineEntity({ + class: TripPhotos, + repository: () => TripPhotosRepository, + uniques: [{ properties: ['trip', 'user', 'photo'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_photos_trip'), + trip_id: p.integer().persist(false).index('idx_trip_photos_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + photo: () => p.manyToOne(TrekPhotos).ref().deleteRule('cascade').hidden().index('idx_trip_photos_photo'), + photo_id: p.integer().persist(false).index('idx_trip_photos_photo'), + shared: p.integer().default(1), + albumLink: () => p.manyToOne(TripAlbumLinks).ref().nullable().hidden(), + album_link_id: p.integer().nullable().persist(false), + added_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts new file mode 100644 index 000000000..903e0931a --- /dev/null +++ b/server/src/db/entities/Trips.entity.ts @@ -0,0 +1,149 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripsRepository } from '../repositories/Trips.repository'; +import { DbTimestampType } from '../types'; +import { BudgetCategoryOrder } from './BudgetCategoryOrder.entity'; +import { BudgetItems } from './BudgetItems.entity'; +import { BudgetSettlements } from './BudgetSettlements.entity'; +import { CollabLinks } from './CollabLinks.entity'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { CollabPolls } from './CollabPolls.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayNotes } from './DayNotes.entity'; +import { Days } from './Days.entity'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { InviteTokens } from './InviteTokens.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyTrips } from './JourneyTrips.entity'; +import { PackingBags } from './PackingBags.entity'; +import { PackingCategoryAssignees } from './PackingCategoryAssignees.entity'; +import { PackingItems } from './PackingItems.entity'; +import { Photos } from './Photos.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayBoundaries } from './RoadtripDayBoundaries.entity'; +import { RoadtripPreferences } from './RoadtripPreferences.entity'; +import { ShareTokens } from './ShareTokens.entity'; +import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity'; +import { TodoItems } from './TodoItems.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripMembers } from './TripMembers.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Users } from './Users.entity'; + +export class Trips { + [EntityRepositoryType]?: TripsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + title!: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string | null = 'EUR'; + cover_image?: string | null; + is_archived?: number | null = 0; + reminder_days?: number | null = 3; + feed_token?: string | null; + created_at?: string | null; + updated_at?: string | null; + budget_category_order_collection = new Collection(this); + budget_items_collection = new Collection(this); + budget_settlements_collection = new Collection(this); + collab_links_collection = new Collection(this); + collab_messages_collection = new Collection(this); + collab_notes_collection = new Collection(this); + collab_polls_collection = new Collection(this); + dawarich_visit_suggestions_collection = new Collection(this); + day_accommodations_collection = new Collection(this); + day_notes_collection = new Collection(this); + days_collection = new Collection(this); + document_connections_collection = new Collection(this); + document_sync_items_collection = new Collection(this); + invite_tokens_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_trips_collection = new Collection(this); + packing_bags_collection = new Collection(this); + packing_category_assignees_collection = new Collection(this); + packing_items_collection = new Collection(this); + photos_collection = new Collection(this); + places_collection = new Collection(this); + reservations_collection = new Collection(this); + roadtrip_day_boundaries_collection = new Collection(this); + roadtrip_preferences_collection = new Collection(this); + share_tokens_collection = new Collection(this); + todo_category_assignees_collection = new Collection(this); + todo_items_collection = new Collection(this); + trip_album_links_collection = new Collection(this); + trip_document_links_collection = new Collection(this); + trip_files_collection = new Collection(this); + trip_invite_tokens_collection = new Collection(this); + trip_members_collection = new Collection(this); + trip_photos_collection = new Collection(this); +} + +export const TripsSchema = defineEntity({ + class: Trips, + repository: () => TripsRepository, + uniques: [ + { + name: 'idx_trips_feed_token', + where: 'feed_token IS NOT NULL', + properties: ['feed_token'], + }, + ], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_trips_user_id'), + user_id: p.integer().persist(false).index('idx_trips_user_id'), + title: p.text(), + description: p.text().nullable(), + start_date: p.text().nullable(), + end_date: p.text().nullable(), + currency: p.text().nullable(), + cover_image: p.text().nullable(), + is_archived: p.integer().nullable(), + reminder_days: p.integer().nullable(), + feed_token: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('trip').hidden(), + budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('trip').hidden(), + collab_links_collection: () => p.oneToMany(CollabLinks).mappedBy('trip').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('trip').hidden(), + collab_notes_collection: () => p.oneToMany(CollabNotes).mappedBy('trip').hidden(), + collab_polls_collection: () => p.oneToMany(CollabPolls).mappedBy('trip').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('trip').hidden(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('trip').hidden(), + day_notes_collection: () => p.oneToMany(DayNotes).mappedBy('trip').hidden(), + days_collection: () => p.oneToMany(Days).mappedBy('trip').hidden(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('trip').hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('trip').hidden(), + invite_tokens_collection: () => p.oneToMany(InviteTokens).mappedBy('trip').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('sourceTrip').hidden(), + journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('trip').hidden(), + packing_bags_collection: () => p.oneToMany(PackingBags).mappedBy('trip').hidden(), + packing_category_assignees_collection: () => p.oneToMany(PackingCategoryAssignees).mappedBy('trip').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('trip').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('trip').hidden(), + places_collection: () => p.oneToMany(Places).mappedBy('trip').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('trip').hidden(), + roadtrip_day_boundaries_collection: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('trip').hidden(), + roadtrip_preferences_collection: () => p.oneToMany(RoadtripPreferences).mappedBy('trip').hidden(), + share_tokens_collection: () => p.oneToMany(ShareTokens).mappedBy('trip').hidden(), + todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('trip').hidden(), + todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('trip').hidden(), + trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('trip').hidden(), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(), + trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(), + trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(), + }, +}); diff --git a/server/src/db/entities/UserNoticeDismissals.entity.ts b/server/src/db/entities/UserNoticeDismissals.entity.ts new file mode 100644 index 000000000..b70ee9d95 --- /dev/null +++ b/server/src/db/entities/UserNoticeDismissals.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { UserNoticeDismissalsRepository } from '../repositories/UserNoticeDismissals.repository'; +import { Users } from './Users.entity'; + +export class UserNoticeDismissals { + [EntityRepositoryType]?: UserNoticeDismissalsRepository; + [PrimaryKeyProp]?: ['user', 'notice_id']; + user!: Ref; + user_id!: number; + notice_id!: string; + dismissed_at!: number; + dismissed_app_version?: string | null; +} + +export const UserNoticeDismissalsSchema = defineEntity({ + class: UserNoticeDismissals, + repository: () => UserNoticeDismissalsRepository, + uniques: [{ properties: ['user', 'notice_id'] }], + properties: { + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + notice_id: p.text().primary(), + dismissed_at: p.integer(), + dismissed_app_version: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts new file mode 100644 index 000000000..572eacbd7 --- /dev/null +++ b/server/src/db/entities/Users.entity.ts @@ -0,0 +1,333 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { UsersRepository } from '../repositories/Users.repository'; +import { DbTimestampType } from '../types'; +import { AssignmentParticipants } from './AssignmentParticipants.entity'; +import { AuditLog } from './AuditLog.entity'; +import { BucketList } from './BucketList.entity'; +import { BudgetItemMembers } from './BudgetItemMembers.entity'; +import { BudgetItemPayers } from './BudgetItemPayers.entity'; +import { BudgetItems } from './BudgetItems.entity'; +import { BudgetSettlements } from './BudgetSettlements.entity'; +import { Categories } from './Categories.entity'; +import { CollabLinks } from './CollabLinks.entity'; +import { CollabMessageReactions } from './CollabMessageReactions.entity'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { CollabPollVotes } from './CollabPollVotes.entity'; +import { CollabPolls } from './CollabPolls.entity'; +import { CollectionMembers } from './CollectionMembers.entity'; +import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Collections } from './Collections.entity'; +import { DawarichConnections } from './DawarichConnections.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { HiddenCountries } from './HiddenCountries.entity'; +import { HiddenRegions } from './HiddenRegions.entity'; +import { IdempotencyKeys } from './IdempotencyKeys.entity'; +import { InviteTokens } from './InviteTokens.entity'; +import { JourneyBooks } from './JourneyBooks.entity'; +import { JourneyContributors } from './JourneyContributors.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyShareTokens } from './JourneyShareTokens.entity'; +import { Journeys } from './Journeys.entity'; +import { McpTokens } from './McpTokens.entity'; +import { NotificationChannelPreferences } from './NotificationChannelPreferences.entity'; +import { Notifications } from './Notifications.entity'; +import { OauthClients } from './OauthClients.entity'; +import { OauthConsents } from './OauthConsents.entity'; +import { OauthTokens } from './OauthTokens.entity'; +import { PackingBags } from './PackingBags.entity'; +import { PackingCategoryAssignees } from './PackingCategoryAssignees.entity'; +import { PackingItemContributors } from './PackingItemContributors.entity'; +import { PackingItems } from './PackingItems.entity'; +import { PackingTemplates } from './PackingTemplates.entity'; +import { PasswordResetTokens } from './PasswordResetTokens.entity'; +import { PlaceRatings } from './PlaceRatings.entity'; +import { PushSubscriptions } from './PushSubscriptions.entity'; +import { ReservationTravelers } from './ReservationTravelers.entity'; +import { Settings } from './Settings.entity'; +import { ShareTokens } from './ShareTokens.entity'; +import { Tags } from './Tags.entity'; +import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity'; +import { TodoItems } from './TodoItems.entity'; +import { TrekPhotos } from './TrekPhotos.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripMembers } from './TripMembers.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Trips } from './Trips.entity'; +import { UserNoticeDismissals } from './UserNoticeDismissals.entity'; +import { VacayEntries } from './VacayEntries.entity'; +import { VacayPlanMembers } from './VacayPlanMembers.entity'; +import { VacayPlans } from './VacayPlans.entity'; +import { VacayShares } from './VacayShares.entity'; +import { VacayUserColors } from './VacayUserColors.entity'; +import { VacayUserSettings } from './VacayUserSettings.entity'; +import { VacayUserYears } from './VacayUserYears.entity'; +import { VisitedCountries } from './VisitedCountries.entity'; +import { VisitedRegions } from './VisitedRegions.entity'; +import { WebauthnChallenges } from './WebauthnChallenges.entity'; +import { WebauthnCredentials } from './WebauthnCredentials.entity'; + +export class Users { + [EntityRepositoryType]?: UsersRepository; + id!: number & Opt; + username!: string; + email!: string; + password_hash!: string; + role: string & Opt = 'user'; + maps_api_key?: string | null; + unsplash_api_key?: string | null; + amap_api_key?: string | null; + openweather_api_key?: string | null; + avatar?: string | null; + oidc_sub?: string | null; + oidc_issuer?: string | null; + last_login?: string | null; + mfa_enabled?: number | null = 0; + mfa_secret?: string | null; + mfa_backup_codes?: string | null; + immich_url?: string | null; + immich_access_token?: string | null; + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; + must_change_password?: number | null = 0; + password_version: number & Opt = 0; + feed_token?: string | null; + is_guest: number & Opt = 0; + created_at?: string | null; + updated_at?: string | null; + immich_api_key?: string | null; + synology_skip_ssl: number & Opt = 0; + synology_did?: string | null; + first_seen_version: string & Opt = '0.0.0'; + login_count: number & Opt = 0; + immich_auto_upload: number & Opt = 0; + airtrail_url?: string | null; + airtrail_api_key?: string | null; + airtrail_allow_insecure_tls?: number | null = 0; + airtrail_write_enabled?: number | null = 0; + display_name?: string | null; + immich_allow_insecure_tls: number & Opt = 0; + assignment_participants_collection = new Collection(this); + audit_log_collection = new Collection(this); + bucket_list_collection = new Collection(this); + budget_item_members_collection = new Collection(this); + budget_item_payers_collection = new Collection(this); + budget_items_collection = new Collection(this); + budget_settlements_collection = new Collection(this); + budget_settlements_collection1 = new Collection(this); + budget_settlements_collection2 = new Collection(this); + categories_collection = new Collection(this); + collab_links_collection = new Collection(this); + collab_message_reactions_collection = new Collection(this); + collab_messages_collection = new Collection(this); + collab_notes_collection = new Collection(this); + collab_poll_votes_collection = new Collection(this); + collab_polls_collection = new Collection(this); + collection_members_collection = new Collection(this); + collection_place_ratings_collection = new Collection(this); + collection_places_collection = new Collection(this); + collection_places_collection1 = new Collection(this); + collections_collection = new Collection(this); + dawarich_connections: Ref | null = null; + dawarich_visit_suggestions_collection = new Collection(this); + document_connections_collection = new Collection(this); + hidden_countries_collection = new Collection(this); + hidden_regions_collection = new Collection(this); + idempotency_keys_collection = new Collection(this); + invite_tokens_collection = new Collection(this); + journey_books_collection = new Collection(this); + journey_books_collection1 = new Collection(this); + journey_contributors_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_share_tokens_collection = new Collection(this); + journeys_collection = new Collection(this); + mcp_tokens_collection = new Collection(this); + notification_channel_preferences_collection = new Collection(this); + notifications_collection = new Collection(this); + notifications_collection1 = new Collection(this); + oauth_clients_collection = new Collection(this); + oauth_consents_collection = new Collection(this); + oauth_tokens_collection = new Collection(this); + packing_bags_collection = new Collection(this); + packing_bag_members_inverse = new Collection(this); + packing_category_assignees_collection = new Collection(this); + packing_item_contributors_collection = new Collection(this); + packing_items_collection = new Collection(this); + packing_item_contributors_inverse = new Collection(this); + packing_item_recipients_inverse = new Collection(this); + packing_templates_collection = new Collection(this); + password_reset_tokens_collection = new Collection(this); + place_ratings_collection = new Collection(this); + push_subscriptions_collection = new Collection(this); + reservation_travelers_collection = new Collection(this); + settings_collection = new Collection(this); + share_tokens_collection = new Collection(this); + tags_collection = new Collection(this); + todo_category_assignees_collection = new Collection(this); + todo_items_collection = new Collection(this); + trek_photos_collection = new Collection(this); + trip_album_links_collection = new Collection(this); + trip_document_links_collection = new Collection(this); + trip_files_collection = new Collection(this); + trip_invite_tokens_collection = new Collection(this); + trip_members_collection = new Collection(this); + trip_members_collection1 = new Collection(this); + trip_photos_collection = new Collection(this); + trips_collection = new Collection(this); + user_notice_dismissals_collection = new Collection(this); + vacay_entries_collection = new Collection(this); + vacay_plan_members_collection = new Collection(this); + vacay_plans_collection = new Collection(this); + vacay_shares_collection = new Collection(this); + vacay_shares_collection1 = new Collection(this); + vacay_user_colors_collection = new Collection(this); + vacay_user_settings: Ref | null = null; + vacay_user_years_collection = new Collection(this); + visited_countries_collection = new Collection(this); + visited_regions_collection = new Collection(this); + webauthn_challenges_collection = new Collection(this); + webauthn_credentials_collection = new Collection(this); +} + +export const UsersSchema = defineEntity({ + class: Users, + repository: () => UsersRepository, + uniques: [ + { + name: 'idx_users_feed_token', + where: 'feed_token IS NOT NULL', + properties: ['feed_token'], + }, + { properties: ['email'] }, + { properties: ['username'] }, + ], + properties: { + id: p.integer().primary(), + username: p.text(), + email: p.text().index('idx_users_email'), + password_hash: p.text(), + role: p.text().default('user'), + maps_api_key: p.text().nullable(), + unsplash_api_key: p.text().nullable(), + amap_api_key: p.text().nullable(), + openweather_api_key: p.text().nullable(), + avatar: p.text().nullable(), + oidc_sub: p.text().nullable(), + oidc_issuer: p.text().nullable(), + last_login: p.type(DbTimestampType).nullable(), + mfa_enabled: p.integer().nullable(), + mfa_secret: p.text().nullable(), + mfa_backup_codes: p.text().nullable(), + immich_url: p.text().nullable(), + immich_access_token: p.text().nullable(), + synology_url: p.text().nullable(), + synology_username: p.text().nullable(), + synology_password: p.text().nullable(), + synology_sid: p.text().nullable(), + must_change_password: p.integer().nullable(), + password_version: p.integer().default(0), + feed_token: p.text().nullable(), + is_guest: p.integer().default(0), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + immich_api_key: p.text().nullable(), + synology_skip_ssl: p.integer().default(0), + synology_did: p.text().nullable(), + first_seen_version: p.text().default('0.0.0'), + login_count: p.integer().default(0), + immich_auto_upload: p.integer().default(0), + airtrail_url: p.text().nullable(), + airtrail_api_key: p.text().nullable(), + airtrail_allow_insecure_tls: p.integer().nullable(), + airtrail_write_enabled: p.integer().nullable(), + display_name: p.text().nullable(), + immich_allow_insecure_tls: p.integer().default(0), + assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(), + audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(), + bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(), + budget_item_members_collection: () => p.oneToMany(BudgetItemMembers).mappedBy('user').hidden(), + budget_item_payers_collection: () => p.oneToMany(BudgetItemPayers).mappedBy('user').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('paidByUser').hidden(), + budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('fromUser').hidden(), + budget_settlements_collection1: () => p.oneToMany(BudgetSettlements).mappedBy('toUser').hidden(), + budget_settlements_collection2: () => p.oneToMany(BudgetSettlements).mappedBy('createdByUser').hidden(), + categories_collection: () => p.oneToMany(Categories).mappedBy('user').hidden(), + collab_links_collection: () => p.oneToMany(CollabLinks).mappedBy('user').hidden(), + collab_message_reactions_collection: () => p.oneToMany(CollabMessageReactions).mappedBy('user').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('user').hidden(), + collab_notes_collection: () => p.oneToMany(CollabNotes).mappedBy('user').hidden(), + collab_poll_votes_collection: () => p.oneToMany(CollabPollVotes).mappedBy('user').hidden(), + collab_polls_collection: () => p.oneToMany(CollabPolls).mappedBy('user').hidden(), + collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('user').hidden(), + collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('user').hidden(), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('owner').hidden(), + collection_places_collection1: () => p.oneToMany(CollectionPlaces).mappedBy('savedByRef').hidden(), + collections_collection: () => p.oneToMany(Collections).mappedBy('owner').hidden(), + dawarich_connections: () => p.oneToOne(DawarichConnections).ref().mappedBy('user').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('user').hidden(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('ownerUser').hidden(), + hidden_countries_collection: () => p.oneToMany(HiddenCountries).mappedBy('user').hidden(), + hidden_regions_collection: () => p.oneToMany(HiddenRegions).mappedBy('user').hidden(), + idempotency_keys_collection: () => p.oneToMany(IdempotencyKeys).mappedBy('user').hidden(), + invite_tokens_collection: () => p.oneToMany(InviteTokens).mappedBy('createdByRef').hidden(), + journey_books_collection: () => p.oneToMany(JourneyBooks).mappedBy('updatedByRef').hidden(), + journey_books_collection1: () => p.oneToMany(JourneyBooks).mappedBy('createdByRef').hidden(), + journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('user').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('author').hidden(), + journey_share_tokens_collection: () => p.oneToMany(JourneyShareTokens).mappedBy('createdByRef').hidden(), + journeys_collection: () => p.oneToMany(Journeys).mappedBy('user').hidden(), + mcp_tokens_collection: () => p.oneToMany(McpTokens).mappedBy('user').hidden(), + notification_channel_preferences_collection: () => p.oneToMany(NotificationChannelPreferences).mappedBy('user').hidden(), + notifications_collection: () => p.oneToMany(Notifications).mappedBy('sender').hidden(), + notifications_collection1: () => p.oneToMany(Notifications).mappedBy('recipient').hidden(), + oauth_clients_collection: () => p.oneToMany(OauthClients).mappedBy('user').hidden(), + oauth_consents_collection: () => p.oneToMany(OauthConsents).mappedBy('user').hidden(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('user').hidden(), + packing_bags_collection: () => p.oneToMany(PackingBags).mappedBy('user').hidden(), + packing_bag_members_inverse: () => p.manyToMany(PackingBags).mappedBy('packing_bag_members').hidden(), + packing_category_assignees_collection: () => p.oneToMany(PackingCategoryAssignees).mappedBy('user').hidden(), + packing_item_contributors_collection: () => p.oneToMany(PackingItemContributors).mappedBy('user').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('owner').hidden(), + packing_item_contributors_inverse: () => p.manyToMany(PackingItems).mappedBy('packing_item_contributors').hidden(), + packing_item_recipients_inverse: () => p.manyToMany(PackingItems).mappedBy('packing_item_recipients').hidden(), + packing_templates_collection: () => p.oneToMany(PackingTemplates).mappedBy('createdByRef').hidden(), + password_reset_tokens_collection: () => p.oneToMany(PasswordResetTokens).mappedBy('user').hidden(), + place_ratings_collection: () => p.oneToMany(PlaceRatings).mappedBy('user').hidden(), + push_subscriptions_collection: () => p.oneToMany(PushSubscriptions).mappedBy('user').hidden(), + reservation_travelers_collection: () => p.oneToMany(ReservationTravelers).mappedBy('user').hidden(), + settings_collection: () => p.oneToMany(Settings).mappedBy('user').hidden(), + share_tokens_collection: () => p.oneToMany(ShareTokens).mappedBy('createdByRef').hidden(), + tags_collection: () => p.oneToMany(Tags).mappedBy('user').hidden(), + todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(), + todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(), + trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(), + trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(), + trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(), + trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(), + trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(), + trips_collection: () => p.oneToMany(Trips).mappedBy('user').hidden(), + user_notice_dismissals_collection: () => p.oneToMany(UserNoticeDismissals).mappedBy('user').hidden(), + vacay_entries_collection: () => p.oneToMany(VacayEntries).mappedBy('user').hidden(), + vacay_plan_members_collection: () => p.oneToMany(VacayPlanMembers).mappedBy('user').hidden(), + vacay_plans_collection: () => p.oneToMany(VacayPlans).mappedBy('owner').hidden(), + vacay_shares_collection: () => p.oneToMany(VacayShares).mappedBy('owner').hidden(), + vacay_shares_collection1: () => p.oneToMany(VacayShares).mappedBy('user').hidden(), + vacay_user_colors_collection: () => p.oneToMany(VacayUserColors).mappedBy('user').hidden(), + vacay_user_settings: () => p.oneToOne(VacayUserSettings).ref().mappedBy('user').hidden(), + vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('user').hidden(), + visited_countries_collection: () => p.oneToMany(VisitedCountries).mappedBy('user').hidden(), + visited_regions_collection: () => p.oneToMany(VisitedRegions).mappedBy('user').hidden(), + webauthn_challenges_collection: () => p.oneToMany(WebauthnChallenges).mappedBy('user').hidden(), + webauthn_credentials_collection: () => p.oneToMany(WebauthnCredentials).mappedBy('user').hidden(), + }, +}); diff --git a/server/src/db/entities/VacayCompanyHolidays.entity.ts b/server/src/db/entities/VacayCompanyHolidays.entity.ts new file mode 100644 index 000000000..97b7f7716 --- /dev/null +++ b/server/src/db/entities/VacayCompanyHolidays.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayCompanyHolidaysRepository } from '../repositories/VacayCompanyHolidays.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayCompanyHolidays { + [EntityRepositoryType]?: VacayCompanyHolidaysRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + date!: string; + note?: string | null = ''; + fraction!: number & Opt; +} + +export const VacayCompanyHolidaysSchema = defineEntity({ + class: VacayCompanyHolidays, + repository: () => VacayCompanyHolidaysRepository, + uniques: [{ properties: ['plan', 'date'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + date: p.text(), + note: p.text().nullable(), + fraction: p.double().defaultRaw(`1`), + }, +}); diff --git a/server/src/db/entities/VacayEntries.entity.ts b/server/src/db/entities/VacayEntries.entity.ts new file mode 100644 index 000000000..75260d407 --- /dev/null +++ b/server/src/db/entities/VacayEntries.entity.ts @@ -0,0 +1,34 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayEntriesRepository } from '../repositories/VacayEntries.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayEntries { + [EntityRepositoryType]?: VacayEntriesRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + user!: Ref; + user_id!: number; + date!: string; + note?: string | null = ''; + fraction!: number & Opt; + kind: string & Opt = 'vacation'; +} + +export const VacayEntriesSchema = defineEntity({ + class: VacayEntries, + repository: () => VacayEntriesRepository, + uniques: [{ properties: ['user', 'plan', 'date'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + date: p.text(), + note: p.text().nullable(), + fraction: p.double().defaultRaw(`1`), + kind: p.text().default('vacation'), + }, +}); diff --git a/server/src/db/entities/VacayHolidayCalendars.entity.ts b/server/src/db/entities/VacayHolidayCalendars.entity.ts new file mode 100644 index 000000000..67e9e2af4 --- /dev/null +++ b/server/src/db/entities/VacayHolidayCalendars.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayHolidayCalendarsRepository } from '../repositories/VacayHolidayCalendars.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayHolidayCalendars { + [EntityRepositoryType]?: VacayHolidayCalendarsRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + type: string & Opt = 'public_holiday'; + region!: string; + label?: string | null; + color: string & Opt = '#fecaca'; + sort_order: number & Opt = 0; +} + +export const VacayHolidayCalendarsSchema = defineEntity({ + class: VacayHolidayCalendars, + repository: () => VacayHolidayCalendarsRepository, + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + type: p.text().default('public_holiday'), + region: p.text(), + label: p.text().nullable(), + color: p.text().default('#fecaca'), + sort_order: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/VacayPlanMembers.entity.ts b/server/src/db/entities/VacayPlanMembers.entity.ts new file mode 100644 index 000000000..f211ed439 --- /dev/null +++ b/server/src/db/entities/VacayPlanMembers.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayPlanMembersRepository } from '../repositories/VacayPlanMembers.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayPlanMembers { + [EntityRepositoryType]?: VacayPlanMembersRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + user!: Ref; + user_id!: number; + status?: string | null = 'pending'; + created_at?: string | null; +} + +export const VacayPlanMembersSchema = defineEntity({ + class: VacayPlanMembers, + repository: () => VacayPlanMembersRepository, + uniques: [{ properties: ['plan', 'user'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + status: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/VacayPlans.entity.ts b/server/src/db/entities/VacayPlans.entity.ts new file mode 100644 index 000000000..86e25320b --- /dev/null +++ b/server/src/db/entities/VacayPlans.entity.ts @@ -0,0 +1,61 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayPlansRepository } from '../repositories/VacayPlans.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; +import { VacayCompanyHolidays } from './VacayCompanyHolidays.entity'; +import { VacayEntries } from './VacayEntries.entity'; +import { VacayHolidayCalendars } from './VacayHolidayCalendars.entity'; +import { VacayPlanMembers } from './VacayPlanMembers.entity'; +import { VacayUserColors } from './VacayUserColors.entity'; +import { VacayUserYears } from './VacayUserYears.entity'; +import { VacayYears } from './VacayYears.entity'; + +export class VacayPlans { + [EntityRepositoryType]?: VacayPlansRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + block_weekends?: number | null = 1; + holidays_enabled?: number | null = 0; + holidays_region?: string | null = ''; + school_holidays_enabled?: number | null = 0; + company_holidays_enabled?: number | null = 1; + carry_over_enabled?: number | null = 1; + created_at?: string | null; + weekend_days?: string | null = '0,6'; + week_start: number & Opt = 1; + vacay_company_holidays_collection = new Collection(this); + vacay_entries_collection = new Collection(this); + vacay_holiday_calendars_collection = new Collection(this); + vacay_plan_members_collection = new Collection(this); + vacay_user_colors_collection = new Collection(this); + vacay_user_years_collection = new Collection(this); + vacay_years_collection = new Collection(this); +} + +export const VacayPlansSchema = defineEntity({ + class: VacayPlans, + repository: () => VacayPlansRepository, + uniques: [{ properties: ['owner'] }], + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + block_weekends: p.integer().nullable(), + holidays_enabled: p.integer().nullable(), + holidays_region: p.text().nullable(), + school_holidays_enabled: p.integer().nullable(), + company_holidays_enabled: p.integer().nullable(), + carry_over_enabled: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + weekend_days: p.text().nullable(), + week_start: p.integer().default(1), + vacay_company_holidays_collection: () => p.oneToMany(VacayCompanyHolidays).mappedBy('plan').hidden(), + vacay_entries_collection: () => p.oneToMany(VacayEntries).mappedBy('plan').hidden(), + vacay_holiday_calendars_collection: () => p.oneToMany(VacayHolidayCalendars).mappedBy('plan').hidden(), + vacay_plan_members_collection: () => p.oneToMany(VacayPlanMembers).mappedBy('plan').hidden(), + vacay_user_colors_collection: () => p.oneToMany(VacayUserColors).mappedBy('plan').hidden(), + vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('plan').hidden(), + vacay_years_collection: () => p.oneToMany(VacayYears).mappedBy('plan').hidden(), + }, +}); diff --git a/server/src/db/entities/VacayShares.entity.ts b/server/src/db/entities/VacayShares.entity.ts new file mode 100644 index 000000000..e12ae50f3 --- /dev/null +++ b/server/src/db/entities/VacayShares.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacaySharesRepository } from '../repositories/VacayShares.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VacayShares { + [EntityRepositoryType]?: VacaySharesRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + user!: Ref; + user_id!: number; + hidden: number & Opt = 0; + created_at?: string | null; +} + +export const VacaySharesSchema = defineEntity({ + class: VacayShares, + repository: () => VacaySharesRepository, + uniques: [{ properties: ['owner', 'user'] }], + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_vacay_shares_user'), + user_id: p.integer().persist(false).index('idx_vacay_shares_user'), + hidden: p.integer().default(0), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/VacayUserColors.entity.ts b/server/src/db/entities/VacayUserColors.entity.ts new file mode 100644 index 000000000..f251f3b60 --- /dev/null +++ b/server/src/db/entities/VacayUserColors.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserColorsRepository } from '../repositories/VacayUserColors.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayUserColors { + [EntityRepositoryType]?: VacayUserColorsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + plan!: Ref; + plan_id!: number; + color?: string | null = '#6366f1'; +} + +export const VacayUserColorsSchema = defineEntity({ + class: VacayUserColors, + repository: () => VacayUserColorsRepository, + uniques: [{ properties: ['user', 'plan'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + color: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayUserSettings.entity.ts b/server/src/db/entities/VacayUserSettings.entity.ts new file mode 100644 index 000000000..b5260e1a0 --- /dev/null +++ b/server/src/db/entities/VacayUserSettings.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserSettingsRepository } from '../repositories/VacayUserSettings.repository'; +import { Users } from './Users.entity'; + +export class VacayUserSettings { + [EntityRepositoryType]?: VacayUserSettingsRepository; + [PrimaryKeyProp]?: 'user'; + user?: Ref | null; + user_id?: number | null; + year_type: string & Opt = 'calendar'; + year_start_month: number & Opt = 1; + year_start_day: number & Opt = 1; + hire_date?: string | null; +} + +export const VacayUserSettingsSchema = defineEntity({ + class: VacayUserSettings, + repository: () => VacayUserSettingsRepository, + properties: { + user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + year_type: p.text().default('calendar'), + year_start_month: p.integer().default(1), + year_start_day: p.integer().default(1), + hire_date: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayUserYears.entity.ts b/server/src/db/entities/VacayUserYears.entity.ts new file mode 100644 index 000000000..5bc9b4569 --- /dev/null +++ b/server/src/db/entities/VacayUserYears.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserYearsRepository } from '../repositories/VacayUserYears.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayUserYears { + [EntityRepositoryType]?: VacayUserYearsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + plan!: Ref; + plan_id!: number; + year!: number; + vacation_days?: number | null = 30; + carried_over?: number | null = 0; +} + +export const VacayUserYearsSchema = defineEntity({ + class: VacayUserYears, + repository: () => VacayUserYearsRepository, + uniques: [{ properties: ['user', 'plan', 'year'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + year: p.integer(), + vacation_days: p.integer().nullable(), + carried_over: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayYears.entity.ts b/server/src/db/entities/VacayYears.entity.ts new file mode 100644 index 000000000..d11c5873d --- /dev/null +++ b/server/src/db/entities/VacayYears.entity.ts @@ -0,0 +1,23 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayYearsRepository } from '../repositories/VacayYears.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayYears { + [EntityRepositoryType]?: VacayYearsRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + year!: number; +} + +export const VacayYearsSchema = defineEntity({ + class: VacayYears, + repository: () => VacayYearsRepository, + uniques: [{ properties: ['plan', 'year'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + year: p.integer(), + }, +}); diff --git a/server/src/db/entities/VisitedCountries.entity.ts b/server/src/db/entities/VisitedCountries.entity.ts new file mode 100644 index 000000000..e2426c81d --- /dev/null +++ b/server/src/db/entities/VisitedCountries.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VisitedCountriesRepository } from '../repositories/VisitedCountries.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VisitedCountries { + [EntityRepositoryType]?: VisitedCountriesRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + country_code!: string; + created_at?: string | null; + source: string & Opt = 'manual'; +} + +export const VisitedCountriesSchema = defineEntity({ + class: VisitedCountries, + repository: () => VisitedCountriesRepository, + uniques: [{ properties: ['user', 'country_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + source: p.text().default('manual'), + }, +}); diff --git a/server/src/db/entities/VisitedRegions.entity.ts b/server/src/db/entities/VisitedRegions.entity.ts new file mode 100644 index 000000000..b311d0b1f --- /dev/null +++ b/server/src/db/entities/VisitedRegions.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VisitedRegionsRepository } from '../repositories/VisitedRegions.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VisitedRegions { + [EntityRepositoryType]?: VisitedRegionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + region_code!: string; + region_name!: string; + country_code!: string; + created_at?: string | null; +} + +export const VisitedRegionsSchema = defineEntity({ + class: VisitedRegions, + repository: () => VisitedRegionsRepository, + uniques: [{ properties: ['user', 'region_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + region_code: p.text(), + region_name: p.text(), + country_code: p.text().index('idx_visited_regions_country'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/WebauthnChallenges.entity.ts b/server/src/db/entities/WebauthnChallenges.entity.ts new file mode 100644 index 000000000..9b677277b --- /dev/null +++ b/server/src/db/entities/WebauthnChallenges.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { WebauthnChallengesRepository } from '../repositories/WebauthnChallenges.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class WebauthnChallenges { + [EntityRepositoryType]?: WebauthnChallengesRepository; + id!: number & Opt; + challenge!: string; + user?: Ref | null; + user_id?: number | null; + type!: string; + expires_at!: number; + created_at?: string | null; +} + +export const WebauthnChallengesSchema = defineEntity({ + class: WebauthnChallenges, + repository: () => WebauthnChallengesRepository, + uniques: [{ properties: ['challenge'] }], + properties: { + id: p.integer().primary(), + challenge: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + type: p.text(), + expires_at: p.integer().index('idx_webauthn_challenges_expires'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/WebauthnCredentials.entity.ts b/server/src/db/entities/WebauthnCredentials.entity.ts new file mode 100644 index 000000000..2a1fa27ab --- /dev/null +++ b/server/src/db/entities/WebauthnCredentials.entity.ts @@ -0,0 +1,42 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { WebauthnCredentialsRepository } from '../repositories/WebauthnCredentials.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class WebauthnCredentials { + [EntityRepositoryType]?: WebauthnCredentialsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + credential_id!: string; + public_key!: Buffer; + counter: number & Opt = 0; + transports?: string | null; + device_type?: string | null; + backed_up: number & Opt = 0; + name?: string | null; + aaguid?: string | null; + created_at?: string | null; + last_used_at?: string | null; +} + +export const WebauthnCredentialsSchema = defineEntity({ + class: WebauthnCredentials, + repository: () => WebauthnCredentialsRepository, + uniques: [{ properties: ['credential_id'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_webauthn_credentials_user'), + user_id: p.integer().persist(false).index('idx_webauthn_credentials_user'), + credential_id: p.text(), + public_key: p.blob(), + counter: p.integer().default(0), + transports: p.text().nullable(), + device_type: p.text().nullable(), + backed_up: p.integer().default(0), + name: p.text().nullable(), + aaguid: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + last_used_at: p.type(DbTimestampType).nullable(), + }, +}); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts new file mode 100644 index 000000000..adde2a392 --- /dev/null +++ b/server/src/db/entities/index.ts @@ -0,0 +1,258 @@ +import type { EntitySchema } from '@mikro-orm/core'; +import { AddonsSchema } from './Addons.entity'; +import { AppSettingsSchema } from './AppSettings.entity'; +import { AssignmentParticipantsSchema } from './AssignmentParticipants.entity'; +import { AuditLogSchema } from './AuditLog.entity'; +import { BucketListSchema } from './BucketList.entity'; +import { BudgetCategoryOrderSchema } from './BudgetCategoryOrder.entity'; +import { BudgetItemMembersSchema } from './BudgetItemMembers.entity'; +import { BudgetItemPayersSchema } from './BudgetItemPayers.entity'; +import { BudgetItemsSchema } from './BudgetItems.entity'; +import { BudgetSettlementsSchema } from './BudgetSettlements.entity'; +import { CategoriesSchema } from './Categories.entity'; +import { CollabLinksSchema } from './CollabLinks.entity'; +import { CollabMessageReactionsSchema } from './CollabMessageReactions.entity'; +import { CollabMessagesSchema } from './CollabMessages.entity'; +import { CollabNotesSchema } from './CollabNotes.entity'; +import { CollabPollVotesSchema } from './CollabPollVotes.entity'; +import { CollabPollsSchema } from './CollabPolls.entity'; +import { CollectionLabelsSchema } from './CollectionLabels.entity'; +import { CollectionMembersSchema } from './CollectionMembers.entity'; +import { CollectionPlaceRatingsSchema } from './CollectionPlaceRatings.entity'; +import { CollectionPlacesSchema } from './CollectionPlaces.entity'; +import { CollectionsSchema } from './Collections.entity'; +import { DawarichConnectionsSchema } from './DawarichConnections.entity'; +import { DawarichVisitSuggestionsSchema } from './DawarichVisitSuggestions.entity'; +import { DayAccommodationsSchema } from './DayAccommodations.entity'; +import { DayAssignmentsSchema } from './DayAssignments.entity'; +import { DayNotesSchema } from './DayNotes.entity'; +import { DaysSchema } from './Days.entity'; +import { DocumentConnectionsSchema } from './DocumentConnections.entity'; +import { DocumentProviderFieldsSchema } from './DocumentProviderFields.entity'; +import { DocumentProvidersSchema } from './DocumentProviders.entity'; +import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity'; +import { FileLinksSchema } from './FileLinks.entity'; +import { GoogleApiUsageSchema } from './GoogleApiUsage.entity'; +import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity'; +import { HiddenCountriesSchema } from './HiddenCountries.entity'; +import { HiddenRegionsSchema } from './HiddenRegions.entity'; +import { IdempotencyKeysSchema } from './IdempotencyKeys.entity'; +import { InviteTokensSchema } from './InviteTokens.entity'; +import { JourneyBooksSchema } from './JourneyBooks.entity'; +import { JourneyContributorsSchema } from './JourneyContributors.entity'; +import { JourneyEntriesSchema } from './JourneyEntries.entity'; +import { JourneyEntryPhotosSchema } from './JourneyEntryPhotos.entity'; +import { JourneyPhotosSchema } from './JourneyPhotos.entity'; +import { JourneyShareTokensSchema } from './JourneyShareTokens.entity'; +import { JourneyTripsSchema } from './JourneyTrips.entity'; +import { JourneysSchema } from './Journeys.entity'; +import { McpTokensSchema } from './McpTokens.entity'; +import { MigrationsSchema } from './Migrations.entity'; +import { NotificationChannelPreferencesSchema } from './NotificationChannelPreferences.entity'; +import { NotificationsSchema } from './Notifications.entity'; +import { OauthClientsSchema } from './OauthClients.entity'; +import { OauthConsentsSchema } from './OauthConsents.entity'; +import { OauthTokensSchema } from './OauthTokens.entity'; +import { PackingBagsSchema } from './PackingBags.entity'; +import { PackingCategoryAssigneesSchema } from './PackingCategoryAssignees.entity'; +import { PackingItemContributorsSchema } from './PackingItemContributors.entity'; +import { PackingItemsSchema } from './PackingItems.entity'; +import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.entity'; +import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity'; +import { PackingTemplatesSchema } from './PackingTemplates.entity'; +import { PasswordResetTokensSchema } from './PasswordResetTokens.entity'; +import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity'; +import { PhotoProvidersSchema } from './PhotoProviders.entity'; +import { PhotosSchema } from './Photos.entity'; +import { PlaceDetailsCacheSchema } from './PlaceDetailsCache.entity'; +import { PlaceRatingsSchema } from './PlaceRatings.entity'; +import { PlaceRegionsSchema } from './PlaceRegions.entity'; +import { PlaceShadowPicksSchema } from './PlaceShadowPicks.entity'; +import { PlacesSchema } from './Places.entity'; +import { PluginActionsSchema } from './PluginActions.entity'; +import { PluginCapabilityAuditSchema } from './PluginCapabilityAudit.entity'; +import { PluginEgressHostsSchema } from './PluginEgressHosts.entity'; +import { PluginEntityMetadataSchema } from './PluginEntityMetadata.entity'; +import { PluginErrorLogSchema } from './PluginErrorLog.entity'; +import { PluginMetaMigrationsSchema } from './PluginMetaMigrations.entity'; +import { PluginOauthStateSchema } from './PluginOauthState.entity'; +import { PluginOauthTokensSchema } from './PluginOauthTokens.entity'; +import { PluginScheduledTasksSchema } from './PluginScheduledTasks.entity'; +import { PluginSettingsFieldsSchema } from './PluginSettingsFields.entity'; +import { PluginUserConfigSchema } from './PluginUserConfig.entity'; +import { PluginUserErasureQueueSchema } from './PluginUserErasureQueue.entity'; +import { PluginsSchema } from './Plugins.entity'; +import { PushSubscriptionsSchema } from './PushSubscriptions.entity'; +import { ReservationDayPositionsSchema } from './ReservationDayPositions.entity'; +import { ReservationEndpointsSchema } from './ReservationEndpoints.entity'; +import { ReservationTravelersSchema } from './ReservationTravelers.entity'; +import { ReservationsSchema } from './Reservations.entity'; +import { RoadtripDayBoundariesSchema } from './RoadtripDayBoundaries.entity'; +import { RoadtripDayTracksSchema } from './RoadtripDayTracks.entity'; +import { RoadtripPreferencesSchema } from './RoadtripPreferences.entity'; +import { RoadtripViasSchema } from './RoadtripVias.entity'; +import { RouteUsageDailySchema } from './RouteUsageDaily.entity'; +import { SchemaVersionSchema } from './SchemaVersion.entity'; +import { SchoolHolidayCountriesSchema } from './SchoolHolidayCountries.entity'; +import { SchoolHolidayPeriodsSchema } from './SchoolHolidayPeriods.entity'; +import { SchoolHolidayRegionsSchema } from './SchoolHolidayRegions.entity'; +import { SettingsSchema } from './Settings.entity'; +import { ShareTokensSchema } from './ShareTokens.entity'; +import { TagsSchema } from './Tags.entity'; +import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity'; +import { TodoItemsSchema } from './TodoItems.entity'; +import { TrekPhotoCacheMetaSchema } from './TrekPhotoCacheMeta.entity'; +import { TrekPhotosSchema } from './TrekPhotos.entity'; +import { TripAlbumLinksSchema } from './TripAlbumLinks.entity'; +import { TripDocumentLinksSchema } from './TripDocumentLinks.entity'; +import { TripFilesSchema } from './TripFiles.entity'; +import { TripInviteTokensSchema } from './TripInviteTokens.entity'; +import { TripMembersSchema } from './TripMembers.entity'; +import { TripPhotosSchema } from './TripPhotos.entity'; +import { TripsSchema } from './Trips.entity'; +import { UserNoticeDismissalsSchema } from './UserNoticeDismissals.entity'; +import { UsersSchema } from './Users.entity'; +import { VacayCompanyHolidaysSchema } from './VacayCompanyHolidays.entity'; +import { VacayEntriesSchema } from './VacayEntries.entity'; +import { VacayHolidayCalendarsSchema } from './VacayHolidayCalendars.entity'; +import { VacayPlanMembersSchema } from './VacayPlanMembers.entity'; +import { VacayPlansSchema } from './VacayPlans.entity'; +import { VacaySharesSchema } from './VacayShares.entity'; +import { VacayUserColorsSchema } from './VacayUserColors.entity'; +import { VacayUserSettingsSchema } from './VacayUserSettings.entity'; +import { VacayUserYearsSchema } from './VacayUserYears.entity'; +import { VacayYearsSchema } from './VacayYears.entity'; +import { VisitedCountriesSchema } from './VisitedCountries.entity'; +import { VisitedRegionsSchema } from './VisitedRegions.entity'; +import { WebauthnChallengesSchema } from './WebauthnChallenges.entity'; +import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity'; + +export const ALL_ENTITIES: readonly EntitySchema[] = [ + AddonsSchema, + AppSettingsSchema, + AssignmentParticipantsSchema, + AuditLogSchema, + BucketListSchema, + BudgetCategoryOrderSchema, + BudgetItemMembersSchema, + BudgetItemPayersSchema, + BudgetItemsSchema, + BudgetSettlementsSchema, + CategoriesSchema, + CollabLinksSchema, + CollabMessageReactionsSchema, + CollabMessagesSchema, + CollabNotesSchema, + CollabPollVotesSchema, + CollabPollsSchema, + CollectionLabelsSchema, + CollectionMembersSchema, + CollectionPlaceRatingsSchema, + CollectionPlacesSchema, + CollectionsSchema, + DawarichConnectionsSchema, + DawarichVisitSuggestionsSchema, + DayAccommodationsSchema, + DayAssignmentsSchema, + DayNotesSchema, + DaysSchema, + DocumentConnectionsSchema, + DocumentProviderFieldsSchema, + DocumentProvidersSchema, + DocumentSyncItemsSchema, + FileLinksSchema, + GoogleApiUsageSchema, + GooglePlacePhotoMetaSchema, + HiddenCountriesSchema, + HiddenRegionsSchema, + IdempotencyKeysSchema, + InviteTokensSchema, + JourneyBooksSchema, + JourneyContributorsSchema, + JourneyEntriesSchema, + JourneyEntryPhotosSchema, + JourneyPhotosSchema, + JourneyShareTokensSchema, + JourneyTripsSchema, + JourneysSchema, + McpTokensSchema, + MigrationsSchema, + NotificationChannelPreferencesSchema, + NotificationsSchema, + OauthClientsSchema, + OauthConsentsSchema, + OauthTokensSchema, + PackingBagsSchema, + PackingCategoryAssigneesSchema, + PackingItemContributorsSchema, + PackingItemsSchema, + PackingTemplateCategoriesSchema, + PackingTemplateItemsSchema, + PackingTemplatesSchema, + PasswordResetTokensSchema, + PhotoProviderFieldsSchema, + PhotoProvidersSchema, + PhotosSchema, + PlaceDetailsCacheSchema, + PlaceRatingsSchema, + PlaceRegionsSchema, + PlaceShadowPicksSchema, + PlacesSchema, + PluginActionsSchema, + PluginCapabilityAuditSchema, + PluginEgressHostsSchema, + PluginEntityMetadataSchema, + PluginErrorLogSchema, + PluginMetaMigrationsSchema, + PluginOauthStateSchema, + PluginOauthTokensSchema, + PluginScheduledTasksSchema, + PluginSettingsFieldsSchema, + PluginUserConfigSchema, + PluginUserErasureQueueSchema, + PluginsSchema, + PushSubscriptionsSchema, + ReservationDayPositionsSchema, + ReservationEndpointsSchema, + ReservationTravelersSchema, + ReservationsSchema, + RoadtripDayBoundariesSchema, + RoadtripDayTracksSchema, + RoadtripPreferencesSchema, + RoadtripViasSchema, + RouteUsageDailySchema, + SchemaVersionSchema, + SchoolHolidayCountriesSchema, + SchoolHolidayPeriodsSchema, + SchoolHolidayRegionsSchema, + SettingsSchema, + ShareTokensSchema, + TagsSchema, + TodoCategoryAssigneesSchema, + TodoItemsSchema, + TrekPhotoCacheMetaSchema, + TrekPhotosSchema, + TripAlbumLinksSchema, + TripDocumentLinksSchema, + TripFilesSchema, + TripInviteTokensSchema, + TripMembersSchema, + TripPhotosSchema, + TripsSchema, + UserNoticeDismissalsSchema, + UsersSchema, + VacayCompanyHolidaysSchema, + VacayEntriesSchema, + VacayHolidayCalendarsSchema, + VacayPlanMembersSchema, + VacayPlansSchema, + VacaySharesSchema, + VacayUserColorsSchema, + VacayUserSettingsSchema, + VacayUserYearsSchema, + VacayYearsSchema, + VisitedCountriesSchema, + VisitedRegionsSchema, + WebauthnChallengesSchema, + WebauthnCredentialsSchema, +]; diff --git a/server/src/db/legacy-baseline.ts b/server/src/db/legacy-baseline.ts new file mode 100644 index 000000000..b5930838b --- /dev/null +++ b/server/src/db/legacy-baseline.ts @@ -0,0 +1,182 @@ +import type { Connection, MigrationInfo } from '@mikro-orm/core'; +import type { Migrator } from '@mikro-orm/migrations'; + +import fs from 'node:fs'; + +/** + * Upgrading an install the retired hand-written runner migrated. + * + * Before MikroORM owned the schema, `db/migrations.ts` applied a positional + * array of steps and recorded how far it got as the single `schema_version` + * row. Such a database has no `mikro_orm_migrations` rows, so on its first boot + * here the migrator would see every migration as pending and replay the whole + * history over a schema that already has it. Eight of those migrations are not + * replay-safe and refuse the boot; the ones that are replay-safe are worse — + * step 26 overwrites every user-set `day_assignments.assignment_time`. So the + * replay must never happen. + * + * Instead, a legacy database is baselined once: every migration whose legacy + * step the database already applied is recorded as executed, and the ordinary + * migrator run then applies only the rest. The baseline itself (`createTables()`) + * is deliberately NOT recorded: the legacy runner ran the running release's + * `createTables()` on every boot before its pending steps, and about thirty + * tables exist only there (no numbered step creates them), so an install older + * than those tables gets them the same way — the baseline's `IF NOT EXISTS` + * DDL runs first, then steps N+1 onwards. + * + * The rows are recorded inside the same transaction as the migrator run, so a + * first boot that fails leaves nothing behind and the next boot baselines again. + * Anything this cannot map exactly refuses the boot rather than guessing — a + * refused boot leaves the file untouched, a wrong guess does not. + */ + +/** + * Each migration ported from the positional runner says which step it was in + * its own docstring. That text is the single source of the mapping — there is + * no second, hand-kept list to drift from it. + */ +const STEP_MARKER = /Legacy migration step (\d+)\b/g; + +const REFUSAL = '[DB] Refusing to boot:'; + +export interface LegacyStepMap { + /** The migrations standing for `createTables()`: everything that sorts ahead of step 1. */ + baseline: string[]; + /** Legacy step number → the migration that carries it. */ + steps: Map; + /** The last step the positional runner ever had. */ + finalStep: number; +} + +type ReadSource = (path: string) => string; + +const readFromDisk: ReadSource = (path) => fs.readFileSync(path, 'utf8'); + +function stepsNamedIn(migration: MigrationInfo, readSource: ReadSource): number[] { + if (!migration.path) { + throw new Error(`${REFUSAL} migration ${migration.name} has no source file to read its legacy step from`); + } + const found = new Set(); + for (const match of readSource(migration.path).matchAll(STEP_MARKER)) found.add(Number(match[1])); + return [...found]; +} + +/** + * Derives the legacy step → migration map from the migrations the migrator + * discovered, in its own application order. + * + * Throws unless the mapping is exact: one step per migration at most, no step + * claimed twice, steps 1..N all present, and applied in step order — the order + * the positional runner used, which is the only order that reproduces what an + * install at step N already has. + */ +export function buildLegacyStepMap( + migrations: readonly MigrationInfo[], + readSource: ReadSource = readFromDisk, +): LegacyStepMap { + const steps = new Map(); + let previousStep = 0; + let firstNumbered = -1; + migrations.forEach((migration, index) => { + const named = stepsNamedIn(migration, readSource); + if (named.length === 0) return; + if (named.length > 1) { + throw new Error(`${REFUSAL} ${migration.name} names more than one legacy step (${named.join(', ')})`); + } + const [step] = named as [number]; + const owner = steps.get(step); + if (owner) throw new Error(`${REFUSAL} legacy step ${step} is claimed by both ${owner} and ${migration.name}`); + if (step < previousStep) { + throw new Error(`${REFUSAL} ${migration.name} (legacy step ${step}) sorts after legacy step ${previousStep}`); + } + if (firstNumbered < 0) firstNumbered = index; + previousStep = step; + steps.set(step, migration.name); + }); + + const finalStep = steps.size; + if (finalStep === 0) throw new Error(`${REFUSAL} no migration names a legacy step`); + for (let step = 1; step <= finalStep; step++) { + if (!steps.has(step)) throw new Error(`${REFUSAL} no migration carries legacy step ${step}`); + } + const baseline = migrations.slice(0, firstNumbered).map((migration) => migration.name); + if (baseline.length === 0) throw new Error(`${REFUSAL} no baseline migration sorts ahead of legacy step 1`); + return { baseline, steps, finalStep }; +} + +/** + * The step a positional-runner database stopped at, or null when this is not + * one: a fresh database has no `schema_version`, and one the migrator has + * already recorded anything in is past the hand-over. + */ +async function legacyVersion(connection: Connection, migrator: Migrator): Promise { + if ((await migrator.getExecuted()).length > 0) return null; + const table: unknown[] = await connection.execute( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'`, + ); + if (table.length === 0) return null; + + const rows: Array<{ version: unknown }> = await connection.execute('SELECT version FROM schema_version'); + if (rows.length !== 1) { + throw new Error(`${REFUSAL} schema_version holds ${rows.length} rows; the legacy runner always kept exactly one`); + } + const [{ version }] = rows as [{ version: unknown }]; + if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { + throw new Error(`${REFUSAL} schema_version ${String(version)} is not a legacy step this release can map`); + } + return version; +} + +/** + * The migrations a positional-runner database already has — steps 1..N, never + * the baseline (see the file header) — or `[]` for every database that is not + * one. Writes nothing; {@link migrateToHead} records them. + */ +export async function planLegacyBaseline( + connection: Connection, + migrator: Migrator, + readSource: ReadSource = readFromDisk, +): Promise { + const version = await legacyVersion(connection, migrator); + if (version === null) return []; + + const map = buildLegacyStepMap(await migrator.getPending(), readSource); + if (version > map.finalStep) { + throw new Error( + `${REFUSAL} schema_version ${version} is newer than the last legacy step this release knows (${map.finalStep})`, + ); + } + + const executed: string[] = []; + for (const [step, name] of map.steps) if (step <= version) executed.push(name); + console.log(`[DB] Legacy install at schema_version ${version} — baselining ${executed.length} migration(s)`); + return executed; +} + +/** + * Runs every pending migration. On a positional-runner database it first + * records steps 1..N as executed, in the SAME transaction as the run, so the + * two commit or roll back together. + */ +export async function migrateToHead( + connection: Connection, + migrator: Migrator, + readSource: ReadSource = readFromDisk, +): Promise { + const baselined = await planLegacyBaseline(connection, migrator, readSource); + const already = new Set(baselined); + const pending = (await migrator.getPending()).filter((migration) => !already.has(migration.name)); + if (pending.length === 0 && baselined.length === 0) return; + console.log(`[DB] Applying ${pending.length} pending migration(s)`); + if (baselined.length === 0) { + await migrator.up(); + return; + } + + const storage = migrator.getStorage(); + await storage.ensureTable(); + await connection.transactional(async (trx) => { + for (const name of baselined) await storage.logMigration({ name }, trx); + await migrator.up({ transaction: trx }); + }); +} diff --git a/server/src/db/migration-utils.ts b/server/src/db/migration-utils.ts new file mode 100644 index 000000000..d5a625e56 --- /dev/null +++ b/server/src/db/migration-utils.ts @@ -0,0 +1,161 @@ +import type { Migration } from '@mikro-orm/migrations'; + +/** + * Helpers shared by the migrations that replay the pre-MikroORM history. + * + * They live outside `db/migrations/` on purpose: the migrator globs that + * directory with `!(*.d).{js,ts,cjs}` and would try to load a helper module as + * a migration class. + */ + +/** A single `pragma_table_info` row — only the column name is ever read. */ +interface ColumnRow { + name: string; +} + +/** + * Adds a column only when the table does not already have it. + * + * The legacy bootstrap ran `schema.ts::createTables()` before the numbered + * migrations, and that file was a *moving* snapshot of the schema rather than a + * frozen v0 — so a database created late already had columns that early steps + * add here. The old runner absorbed the clash by catching "duplicate column + * name"; this checks first instead, so a genuine failure still propagates. + * + * `definition` is the full column spec, e.g. `status TEXT DEFAULT 'pending'`. + */ +export async function addColumnIfMissing( + migration: Migration, + table: string, + column: string, + definition: string, +): Promise { + const existing = (await migration.execute('select name from pragma_table_info(?) where name = ?', [ + table, + column, + ])) as ColumnRow[]; + if (existing.length > 0) return; + await migration.execute(`alter table \`${table}\` add column ${definition}`); +} + +/** True when `table` exists in the current database. */ +export async function tableExists(migration: Migration, table: string): Promise { + const rows = await migration.execute(`select name from sqlite_master where type = 'table' and name = ?`, [table]); + return rows.length > 0; +} + +/** The `CREATE TABLE` DDL SQLite stored for `table`, or null when it does not exist. */ +export async function tableSql(migration: Migration, table: string): Promise { + const rows = (await migration.execute(`select sql from sqlite_master where name = ?`, [table])) as { + sql: string | null; + }[]; + return rows[0]?.sql ?? null; +} + +/** + * Runs a statement whose failure the legacy step logged and swallowed. + * + * Kept as its own helper so "this step is allowed to fail" stays visible in the + * generated migrations instead of hiding behind a bare try/catch. + */ +export async function execBestEffort(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** The column names of `table`, or an empty set when the table does not exist. */ +export async function columnNames(migration: Migration, table: string): Promise> { + const rows = (await migration.execute('select name from pragma_table_info(?)', [table])) as ColumnRow[]; + return new Set(rows.map((r) => r.name)); +} + +/** + * Runs a statement that the legacy step allowed to fail *only* because the table + * it touches may not exist yet on that database. Any other error still throws. + */ +export async function execUnlessTableMissing(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such table')) throw err; + } +} + +/** + * Runs a group of statements the legacy step wrapped in a single swallowing + * try/catch. A failure part-way through leaves the earlier statements applied, + * which is exactly what the original did. + */ +export async function bestEffort(run: () => Promise): Promise { + try { + await run(); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** A booked stay whose check-in day has no matching stop on the plan. */ +interface UnplacedStay { + id: number; + place_id: number; + start_day_id: number; +} + +/** + * Gives every booked stay the day stop it would get today, and returns how many + * were placed. + * + * Road-trip mode builds its drive out of `day_assignments` alone, so a hotel + * booked in Days mode was invisible there. Two legacy steps run this same sweep: + * once at the release, and once more afterwards for the bookings written in the + * seconds while the old container was still answering. The `NOT EXISTS` decides + * both times, so repeating it is safe — a stay that already has its stop is + * passed over, and a place the traveller planned by hand keeps its row unclaimed. + */ +export async function attachStayStopsToCheckInDay(migration: Migration): Promise { + const stays = (await migration.execute(` + SELECT a.id, a.place_id, a.start_day_id + FROM day_accommodations a + WHERE a.place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM day_assignments da + WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id + ) + ORDER BY a.id + `)) as UnplacedStay[]; + + for (const stay of stays) { + // Arriving somewhere is what the day was for, so the stop goes last — the + // same position a stay booked today lands in. + await migration.execute( + `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) + VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)`, + [stay.start_day_id, stay.place_id, stay.start_day_id, stay.id], + ); + await migration.execute( + `UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')`, + [stay.place_id], + ); + } + + return stays.length; +} + +/** + * Keeps place_regions in step with the place it was resolved from (#2527): a + * change to lat, lng or address drops the cached row. Legacy steps 244 and 246 + * both create it, see there. + */ +export async function createPlaceRegionsFollowPlaceTrigger(migration: Migration): Promise { + await migration.execute(` + CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place + AFTER UPDATE OF lat, lng, address ON places + WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address + BEGIN + DELETE FROM place_regions WHERE place_id = NEW.id; + END + `); +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts deleted file mode 100644 index 31f7dc6a8..000000000 --- a/server/src/db/migrations.ts +++ /dev/null @@ -1,5484 +0,0 @@ -import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; -import { readEnv } from '../app-config'; -import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; -import { seedDocumentProviders } from './document-provider-seed'; -import { reseatBookedNights } from './reseat-booked-nights'; - -import Database from 'better-sqlite3'; -import fs from 'fs'; -import path from 'path'; -import zlib from 'zlib'; - -/** Returns true if any collision was encountered (renamed row). */ -export function trimUserWhitespace(db: Database.Database): boolean { - type DirtyRow = { id: number; username?: string; email?: string }; - let hadCollision = false; - - const dirtyUsernames = db - .prepare(`SELECT id, username FROM users WHERE username != TRIM(username)`) - .all() as DirtyRow[]; - - for (const row of dirtyUsernames) { - const trimmed = row.username!.trim(); - const collision = db - .prepare(`SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`) - .get(trimmed, row.id) as { id: number } | undefined; - - const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; - if (collision) { - hadCollision = true; - console.warn( - `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + - `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + - `collides with user id=${collision.id}. Renamed to "${final}". ` + - `Manual review required.`, - ); - } else { - console.warn( - `[migration] Trimmed username for user id=${row.id}: ` + `${JSON.stringify(row.username)} → "${final}"`, - ); - } - db.prepare(`UPDATE users SET username = ? WHERE id = ?`).run(final, row.id); - } - - const dirtyEmails = db.prepare(`SELECT id, email FROM users WHERE email != TRIM(email)`).all() as DirtyRow[]; - - for (const row of dirtyEmails) { - const trimmed = row.email!.trim(); - const collision = db - .prepare(`SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`) - .get(trimmed, row.id) as { id: number } | undefined; - - let final = trimmed; - if (collision) { - hadCollision = true; - const at = trimmed.lastIndexOf('@'); - final = - at > 0 ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` : `${trimmed}__migrated_${row.id}`; - console.warn( - `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + - `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + - `collides with user id=${collision.id}. Renamed to "${final}". ` + - `User cannot sign in with this email until manually corrected.`, - ); - } else { - console.warn(`[migration] Trimmed email for user id=${row.id}: ` + `${JSON.stringify(row.email)} → "${final}"`); - } - db.prepare(`UPDATE users SET email = ? WHERE id = ?`).run(final, row.id); - } - - return hadCollision; -} - -/** - * Keeps place_regions in step with the place it was resolved from (#2527): a - * change to lat, lng or address drops the cached row. Migrations 244 and 246 - * both create it, see there. - */ -function createPlaceRegionsFollowPlaceTrigger(db: Database.Database): void { - db.exec(` - CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place - AFTER UPDATE OF lat, lng, address ON places - WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address - BEGIN - DELETE FROM place_regions WHERE place_id = NEW.id; - END - `); -} - -function runMigrations(db: Database.Database): void { - db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); - const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; - let currentVersion = versionRow?.version ?? 0; - - if (currentVersion === 0) { - const hasUnsplash = db.prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'").get(); - if (hasUnsplash) { - currentVersion = 19; - db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion); - console.log('[DB] Schema already up-to-date, setting version to', currentVersion); - } else { - db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0); - } - } - - type Migration = (() => void) | { raw: () => void }; - const migrations: Migration[] = [ - () => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'), - () => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'), - () => db.exec('ALTER TABLE places ADD COLUMN notes TEXT'), - () => db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'), - () => db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"), - () => db.exec('ALTER TABLE days ADD COLUMN title TEXT'), - () => db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"), - () => - db.exec( - 'ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL', - ), - () => db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"), - () => db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'), - () => db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"), - () => db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'), - () => db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'), - () => db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN oidc_issuer TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN last_login DATETIME'), - () => { - const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get() as - | { sql: string } - | undefined; - if (schema?.sql?.includes('NOT NULL DEFAULT 1')) { - db.exec(` - CREATE TABLE budget_items_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'Other', - name TEXT NOT NULL, - total_price REAL NOT NULL DEFAULT 0, - persons INTEGER DEFAULT NULL, - days INTEGER DEFAULT NULL, - note TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - INSERT INTO budget_items_new SELECT * FROM budget_items; - DROP TABLE budget_items; - ALTER TABLE budget_items_new RENAME TO budget_items; - `); - } - }, - () => { - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec("ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT 'none'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec(` - UPDATE day_assignments SET - reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), - reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), - reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) - WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') - `); - console.log('[DB] Migrated reservation data from places to day_assignments'); - } catch (e: unknown) { - console.error('[DB] Migration 22 data copy error:', e instanceof Error ? e.message : e); - } - }, - () => { - try { - db.exec( - 'ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS assignment_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(assignment_id, user_id) - ) - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS collab_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - category TEXT DEFAULT 'General', - title TEXT NOT NULL, - content TEXT, - color TEXT DEFAULT '#6366f1', - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS collab_polls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - question TEXT NOT NULL, - options TEXT NOT NULL, - multiple INTEGER DEFAULT 0, - closed INTEGER DEFAULT 0, - deadline TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS collab_poll_votes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - option_index INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(poll_id, user_id, option_index) - ); - CREATE TABLE IF NOT EXISTS collab_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - text TEXT NOT NULL, - reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); - `); - try { - db.prepare( - "INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)", - ).run(); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec(` - UPDATE day_assignments SET - assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), - assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) - `); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS budget_item_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - paid INTEGER NOT NULL DEFAULT 0, - UNIQUE(budget_item_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id); - CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS collab_message_reactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - emoji TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(message_id, user_id, emoji) - ); - CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id); - `); - }, - () => { - try { - db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec( - 'ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - token TEXT UNIQUE NOT NULL, - max_uses INTEGER NOT NULL DEFAULT 1, - used_count INTEGER NOT NULL DEFAULT 0, - expires_at TEXT, - created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category_name TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(trip_id, category_name, user_id) - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 - )`); - // Recreate items table with category_id FK (replaces old template_id-based schema) - try { - db.exec('DROP TABLE IF EXISTS packing_template_items'); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - db.exec(`CREATE TABLE packing_template_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_bags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#6366f1', - weight_limit_grams INTEGER, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - try { - db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, country_code) - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS bucket_list ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - lat REAL, - lng REAL, - country_code TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - // Configurable weekend days - try { - db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Immich integration - try { - db.exec('ALTER TABLE users ADD COLUMN immich_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN immich_api_key TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - immich_asset_id TEXT NOT NULL, - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, immich_asset_id) - )`); - // Add memories addon - try { - db.prepare('INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)').run( - 'memories', - 'Photos', - 'trip', - 'Image', - 0, - 7, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - // Allow files to be linked to multiple reservations/assignments - db.exec(`CREATE TABLE IF NOT EXISTS file_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, - reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, - assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(file_id, reservation_id), - UNIQUE(file_id, assignment_id), - UNIQUE(file_id, place_id) - )`); - }, - () => { - // Add day_plan_position to reservations for persistent transport ordering in day timeline - try { - db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Add paid_by_user_id to budget_items for expense tracking / settlement - try { - db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Add target_date to bucket_list for optional visit planning - try { - db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Notification preferences per user - db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - notify_trip_invite INTEGER DEFAULT 1, - notify_booking_change INTEGER DEFAULT 1, - notify_trip_reminder INTEGER DEFAULT 1, - notify_vacay_invite INTEGER DEFAULT 1, - notify_photos_shared INTEGER DEFAULT 1, - notify_collab_message INTEGER DEFAULT 1, - notify_packing_tagged INTEGER DEFAULT 1, - notify_webhook INTEGER DEFAULT 0, - UNIQUE(user_id) - )`); - }, - () => { - // Add missing notification preference columns for existing tables - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Public share links for read-only trip access - db.exec(`CREATE TABLE IF NOT EXISTS share_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - created_by INTEGER NOT NULL REFERENCES users(id), - share_map INTEGER DEFAULT 1, - share_bookings INTEGER DEFAULT 1, - share_packing INTEGER DEFAULT 0, - share_budget INTEGER DEFAULT 0, - share_collab INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - // Add permission columns to share_tokens - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Audit log - db.exec(` - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - resource TEXT, - details TEXT, - ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); - `); - }, - () => { - // MFA backup/recovery codes - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // MCP long-lived API tokens - () => - db.exec(` - CREATE TABLE IF NOT EXISTS mcp_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - token_prefix TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ) - `), - // MCP addon entry - () => { - try { - db.prepare( - 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Index on mcp_tokens.token_hash - () => - db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash) - `), - // Ensure MCP addon type is 'integration' - () => { - try { - db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Encrypt any plaintext oidc_client_secret left in app_settings - () => { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_secret'").get() as - | { value: string } - | undefined; - if (row?.value && !row.value.startsWith('enc:v1:')) { - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run( - encrypt_api_key(row.value), - ); - } - }, - // Encrypt any plaintext smtp_pass left in app_settings - () => { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as - | { value: string } - | undefined; - if (row?.value && !row.value.startsWith('enc:v1:')) { - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); - } - }, - // Encrypt any plaintext immich_api_key values in the users table - () => { - const rows = db - .prepare( - "SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'", - ) - .all() as { id: number; immich_api_key: string }[]; - for (const row of rows) { - db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id); - } - }, - () => { - try { - db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS trip_album_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - immich_album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, immich_album_id) - ); - CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), - scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), - target INTEGER NOT NULL, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title_key TEXT NOT NULL, - title_params TEXT DEFAULT '{}', - text_key TEXT NOT NULL, - text_params TEXT DEFAULT '{}', - positive_text_key TEXT, - negative_text_key TEXT, - positive_callback TEXT, - negative_callback TEXT, - response TEXT CHECK(response IN ('positive', 'negative')), - navigate_text_key TEXT, - navigate_target TEXT, - is_read INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); - `); - }, - () => { - // Normalize trip_photos to provider-based schema used by current routes - const tripPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'") - .get(); - if (!tripPhotosExists) { - db.exec(` - CREATE TABLE trip_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - asset_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'immich', - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, asset_id, provider) - ); - CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); - `); - } else { - const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - const assetSource = names.has('asset_id') - ? 'asset_id' - : names.has('immich_asset_id') - ? 'immich_asset_id' - : null; - if (assetSource) { - const providerExpr = names.has('provider') - ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" - : "'immich'"; - const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; - const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; - - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - asset_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'immich', - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, asset_id, provider) - ); - `); - - db.exec(` - INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) - SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} - FROM trip_photos - WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' - `); - - db.exec('DROP TABLE trip_photos'); - db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); - } - } - }, - () => { - // Normalize trip_album_links to provider + album_id schema used by current routes - const linksExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'") - .get(); - if (!linksExists) { - db.exec(` - CREATE TABLE trip_album_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, provider, album_id) - ); - CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); - `); - } else { - const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - const albumIdSource = names.has('album_id') - ? 'album_id' - : names.has('immich_album_id') - ? 'immich_album_id' - : null; - if (albumIdSource) { - const providerExpr = names.has('provider') - ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" - : "'immich'"; - const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; - const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; - const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; - const createdAtExpr = names.has('created_at') - ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' - : 'CURRENT_TIMESTAMP'; - - db.exec(` - CREATE TABLE trip_album_links_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, provider, album_id) - ); - `); - - db.exec(` - INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) - SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} - FROM trip_album_links - WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' - `); - - db.exec('DROP TABLE trip_album_links'); - db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); - } - } - }, - () => { - // Add Synology credential columns for existing databases - try { - db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Seed Synology Photos provider and fields in existing databases - try { - db.prepare( - ` - INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - icon = excluded.icon, - enabled = excluded.enabled, - sort_order = excluded.sort_order - `, - ).run( - 'synologyphotos', - 'Synology Photos', - 'Synology Photos integration with separate account settings', - 'Image', - 0, - 1, - ); - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - try { - const insertField = db.prepare(` - INSERT INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(provider_id, field_key) DO UPDATE SET - label = excluded.label, - input_type = excluded.input_type, - placeholder = excluded.placeholder, - required = excluded.required, - secret = excluded.secret, - settings_key = excluded.settings_key, - payload_key = excluded.payload_key, - sort_order = excluded.sort_order - `); - insertField.run( - 'synologyphotos', - 'synology_url', - 'providerUrl', - 'url', - 'https://synology.example.com', - 1, - 0, - 'synology_url', - 'synology_url', - 0, - ); - insertField.run( - 'synologyphotos', - 'synology_username', - 'providerUsername', - 'text', - 'Username', - 1, - 0, - 'synology_username', - 'synology_username', - 1, - ); - insertField.run( - 'synologyphotos', - 'synology_password', - 'providerPassword', - 'password', - 'Password', - 1, - 1, - null, - 'synology_password', - 2, - ); - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - }, - () => { - // Remove the stored config column from photo_providers now that it is generated from provider id. - const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - if (!names.has('config')) return; - - db.exec('ALTER TABLE photo_providers DROP COLUMN config'); - }, - () => { - const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - if (names.has('asset_id') && !names.has('immich_asset_id')) return; - db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); - db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); - }, - () => { - // Track which album link each photo was synced from - try { - db.exec( - 'ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)'); - }, - // Migration 68: Todo items - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS todo_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - checked INTEGER DEFAULT 0, - category TEXT, - sort_order INTEGER DEFAULT 0, - due_date TEXT, - description TEXT, - assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - priority INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id); - - CREATE TABLE IF NOT EXISTS todo_category_assignees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category_name TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(trip_id, category_name, user_id) - ); - `); - }, - () => { - try { - db.exec("UPDATE addons SET enabled = 0 WHERE id = 'memories'"); - } catch (err) { - // Non-fatal: the addons table may not exist yet on very old databases. - // Disabling the legacy memories addon is best-effort, but we no longer - // swallow the error silently. - console.warn("[migrations] Non-fatal: failed to disable legacy 'memories' addon:", err); - } - }, - // Migration 69: Place region cache for sub-national Atlas regions - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_regions ( - place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - region_code TEXT NOT NULL, - region_name TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code); - CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS visited_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - region_code TEXT NOT NULL, - region_name TEXT NOT NULL, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, region_code) - ); - CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code); - `); - }, - // Migration 71: Normalized per-user per-channel notification preferences - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS notification_channel_preferences ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - channel TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (user_id, event_type, channel) - ); - CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); - `); - - // Migrate data from old notification_preferences table (may not exist on fresh installs) - const tableExists = - (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_preferences'").get() as - | { name: string } - | undefined) != null; - const oldPrefs: Array> = tableExists - ? (db.prepare('SELECT * FROM notification_preferences').all() as Array>) - : []; - const eventCols: Record = { - trip_invite: 'notify_trip_invite', - booking_change: 'notify_booking_change', - trip_reminder: 'notify_trip_reminder', - vacay_invite: 'notify_vacay_invite', - photos_shared: 'notify_photos_shared', - collab_message: 'notify_collab_message', - packing_tagged: 'notify_packing_tagged', - }; - const insert = db.prepare( - 'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)', - ); - const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => { - for (const [userId, eventType, channel, enabled] of rows) { - insert.run(userId, eventType, channel, enabled); - } - }); - - for (const row of oldPrefs) { - const userId = row.user_id as number; - const webhookEnabled = (row.notify_webhook as number) ?? 0; - const rows: Array<[number, string, string, number]> = []; - for (const [eventType, col] of Object.entries(eventCols)) { - const emailEnabled = (row[col] as number) ?? 1; - // Only insert if disabled (no row = enabled is our default) - if (!emailEnabled) rows.push([userId, eventType, 'email', 0]); - if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]); - } - if (rows.length > 0) insertMany(rows); - } - - // Copy existing single-channel setting to new plural key - db.exec(` - INSERT OR IGNORE INTO app_settings (key, value) - SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel'; - `); - }, - // Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71) - () => { - db.exec('DROP TABLE IF EXISTS notification_preferences;'); - }, - // Migration 73: Add reservation_id to budget_items for linking budget entries to reservations - () => { - try { - db.exec( - 'ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 74: Add quantity to packing_items + user_id to packing_bags + bag_members table - () => { - try { - db.exec('ALTER TABLE packing_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec( - 'ALTER TABLE packing_bags ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE TABLE IF NOT EXISTS packing_bag_members ( - bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (bag_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id); - `); - // Migrate existing single user_id to bag_members - const bagsWithUser = db.prepare('SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL').all() as { - id: number; - user_id: number; - }[]; - const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); - for (const b of bagsWithUser) ins.run(b.id, b.user_id); - }, - // Migration: Per-day positions for multi-day reservations - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_day_positions ( - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - position REAL NOT NULL, - PRIMARY KEY (reservation_id, day_id) - ); - `); - // Migrate existing global positions to per-day entries - const reservations = db - .prepare( - 'SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL', - ) - .all() as any[]; - const ins = db.prepare( - 'INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)', - ); - for (const r of reservations) { - const startDate = r.reservation_time?.split('T')[0]; - const endDate = r.reservation_end_time?.split('T')[0] || startDate; - if (!startDate) continue; - const matchingDays = db - .prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?') - .all(r.trip_id, startDate, endDate) as { id: number }[]; - for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position); - } - }, - // Migration: Budget category ordering - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS budget_category_order ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (trip_id, category) - ); - `); - // Seed existing categories with alphabetical order - const rows = db - .prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category') - .all() as { trip_id: number; category: string }[]; - const ins = db.prepare( - 'INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)', - ); - let lastTripId = -1; - let idx = 0; - for (const r of rows) { - if (r.trip_id !== lastTripId) { - lastTripId = r.trip_id; - idx = 0; - } - ins.run(r.trip_id, r.category, idx++); - } - }, - // Migration: Naver list import addon (default off) - () => { - try { - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ).run( - 'naver_list_import', - 'Naver List Import', - 'Import places from shared Naver Maps lists', - 'trip', - 'Link2', - 0, - 13, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: OAuth 2.1 clients, consents, and tokens for MCP - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS oauth_clients ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - client_id TEXT UNIQUE NOT NULL, - client_secret_hash TEXT NOT NULL, - redirect_uris TEXT NOT NULL DEFAULT '[]', - allowed_scopes TEXT NOT NULL DEFAULT '[]', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id); - - CREATE TABLE IF NOT EXISTS oauth_consents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - scopes TEXT NOT NULL DEFAULT '[]', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(client_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS oauth_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - access_token_hash TEXT UNIQUE NOT NULL, - refresh_token_hash TEXT UNIQUE NOT NULL, - scopes TEXT NOT NULL DEFAULT '[]', - access_token_expires_at DATETIME NOT NULL, - refresh_token_expires_at DATETIME NOT NULL, - revoked_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash); - `); - }, - // Migration: Refresh-token rotation chain tracking for replay detection - () => { - db.exec(` - ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id); - CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id); - `); - }, - // Migration: Public client support for browser-initiated dynamic registration (DCR) - () => { - db.exec(` - ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0; - ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui'; - `); - }, - // Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients - // (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction) - { - raw: () => { - db.exec('PRAGMA foreign_keys = OFF'); - try { - db.transaction(() => { - db.exec(` - CREATE TABLE IF NOT EXISTS oauth_clients_new ( - id TEXT PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - client_id TEXT UNIQUE NOT NULL, - client_secret_hash TEXT NOT NULL, - redirect_uris TEXT NOT NULL DEFAULT '[]', - allowed_scopes TEXT NOT NULL DEFAULT '[]', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_public INTEGER NOT NULL DEFAULT 0, - created_via TEXT NOT NULL DEFAULT 'settings_ui' - ) - `); - db.exec( - `INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`, - ); - db.exec(`DROP TABLE oauth_clients`); - db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); - })(); - } finally { - db.exec('PRAGMA foreign_keys = ON'); - } - }, - }, - // Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos - () => { - const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'hint')) { - db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`); - } - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) - `); - db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); - db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`); - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) - `); - db.exec(` - UPDATE photo_provider_fields - SET hint = 'providerUrlHintSynology' - WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' - `); - }, - // Migration 84: Journey addon — trip tracking & travel journal - () => { - // Register addon (disabled by default — opt-in) - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) - VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35) - `, - ).run(); - - // Core journey table - db.exec(` - CREATE TABLE IF NOT EXISTS journeys ( - id TEXT PRIMARY KEY, - trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - cover_image TEXT, - status TEXT NOT NULL DEFAULT 'draft', - started_at TEXT, - ended_at TEXT, - is_public INTEGER NOT NULL DEFAULT 0, - public_token TEXT UNIQUE, - settings TEXT DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Check-ins — visited locations - db.exec(` - CREATE TABLE IF NOT EXISTS journey_checkins ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - name TEXT NOT NULL, - lat REAL, - lng REAL, - address TEXT, - country_code TEXT, - notes TEXT, - checked_in_at TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'manual', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Journal entries — daily stories - db.exec(` - CREATE TABLE IF NOT EXISTS journey_entries ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, - entry_date TEXT NOT NULL, - title TEXT, - body TEXT, - mood TEXT, - weather TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Photos — local uploads + provider references (Immich/Synology) - db.exec(` - CREATE TABLE IF NOT EXISTS journey_photos ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, - entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL, - storage_type TEXT NOT NULL DEFAULT 'local', - asset_id TEXT, - file_path TEXT, - thumbnail_path TEXT, - original_name TEXT, - mime_type TEXT, - size_bytes INTEGER, - caption TEXT, - taken_at TEXT, - lat REAL, - lng REAL, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // GPS trail points (Dawarich integration) - db.exec(` - CREATE TABLE IF NOT EXISTS journey_location_trail ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - lat REAL NOT NULL, - lng REAL NOT NULL, - altitude REAL, - accuracy REAL, - recorded_at TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'dawarich' - ) - `); - - // Indexes - db.exec(` - CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id); - CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id); - CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token); - CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at); - CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date); - CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id); - CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id); - CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id); - CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at); - `); - }, - // Migration 85: Journal — richer entry fields for magazine-style design - () => { - // Highlight tags (JSON array), visibility control, hero photo, color accent - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Check-in: allow a single cover photo reference - try { - db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Photos: add caption edit timestamp for gallery ordering - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 86: Journey multi-trip support + sharing/collaboration - () => { - // Junction table: journey can include multiple trips - db.exec(` - CREATE TABLE IF NOT EXISTS journey_trips ( - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - PRIMARY KEY (journey_id, trip_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)'); - - // Sharing: invite users to a journey - db.exec(` - CREATE TABLE IF NOT EXISTS journey_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'viewer', - invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - UNIQUE(journey_id, user_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)'); - - // author tracking on entries and checkins - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 87: Journey rebuild — new schema with trip sync - () => { - // Migrate existing data from old tables into backup, then rebuild - const hasOldJourneys = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'").get(); - - let oldJourneys: any[] = []; - let oldEntries: any[] = []; - let oldPhotos: any[] = []; - - if (hasOldJourneys) { - // Save existing data before dropping - try { - oldJourneys = db.prepare('SELECT * FROM journeys').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - oldEntries = db.prepare('SELECT * FROM journey_entries').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Drop all old journey tables - db.exec('DROP TABLE IF EXISTS journey_location_trail'); - db.exec('DROP TABLE IF EXISTS journey_photos'); - db.exec('DROP TABLE IF EXISTS journey_entries'); - db.exec('DROP TABLE IF EXISTS journey_checkins'); - db.exec('DROP TABLE IF EXISTS journey_members'); - db.exec('DROP TABLE IF EXISTS journey_trips'); - db.exec('DROP TABLE IF EXISTS journeys'); - } - - // New schema - db.exec(` - CREATE TABLE journeys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - title TEXT NOT NULL, - subtitle TEXT, - cover_gradient TEXT, - status TEXT DEFAULT 'draft', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE journey_trips ( - journey_id INTEGER NOT NULL, - trip_id INTEGER NOT NULL, - added_at INTEGER NOT NULL, - PRIMARY KEY (journey_id, trip_id), - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE - ) - `); - - db.exec(` - CREATE TABLE journey_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL, - source_trip_id INTEGER, - source_place_id INTEGER, - author_id INTEGER NOT NULL, - type TEXT NOT NULL, - title TEXT, - story TEXT, - entry_date TEXT NOT NULL, - entry_time TEXT, - location_name TEXT, - location_lat REAL, - location_lng REAL, - mood TEXT, - weather TEXT, - tags TEXT, - visibility TEXT DEFAULT 'private', - sort_order INTEGER DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL, - FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL, - FOREIGN KEY (author_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE journey_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - file_path TEXT NOT NULL, - thumbnail_path TEXT, - caption TEXT, - sort_order INTEGER DEFAULT 0, - width INTEGER, - height INTEGER, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ) - `); - - db.exec(` - CREATE TABLE journey_contributors ( - journey_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - role TEXT NOT NULL, - added_at INTEGER NOT NULL, - PRIMARY KEY (journey_id, user_id), - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - // Indexes - db.exec(` - CREATE INDEX idx_journeys_user ON journeys(user_id); - CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date); - CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id); - CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id); - CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id); - CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id); - `); - - // Re-import old data if it existed - if (oldJourneys.length > 0) { - const ts = Date.now(); - const journeyIdMap = new Map(); // old TEXT id -> new INTEGER id - - for (const j of oldJourneys) { - const res = db - .prepare( - ` - INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `, - ) - .run( - j.user_id, - j.title || 'Untitled Journey', - j.description || null, - j.status || 'draft', - j.created_at ? new Date(j.created_at).getTime() : ts, - j.updated_at ? new Date(j.updated_at).getTime() : ts, - ); - journeyIdMap.set(j.id, Number(res.lastInsertRowid)); - - // Add owner as contributor - db.prepare( - ` - INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) - VALUES (?, ?, 'owner', ?) - `, - ).run(Number(res.lastInsertRowid), j.user_id, ts); - - // Link trip if old journey had one - if (j.trip_id) { - try { - db.prepare( - ` - INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) - VALUES (?, ?, ?) - `, - ).run(Number(res.lastInsertRowid), j.trip_id, ts); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - } - - // Migrate entries - const entryIdMap = new Map(); - for (const e of oldEntries) { - const newJourneyId = journeyIdMap.get(e.journey_id); - if (!newJourneyId) continue; - - const res = db - .prepare( - ` - INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - newJourneyId, - e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1, - e.title || null, - e.body || null, - e.entry_date || new Date().toISOString().split('T')[0], - e.place_name || null, - e.lat || null, - e.lng || null, - e.mood || null, - e.weather || null, - e.visibility || 'private', - e.sort_order || 0, - e.created_at ? new Date(e.created_at).getTime() : ts, - e.updated_at ? new Date(e.updated_at).getTime() : ts, - ); - entryIdMap.set(e.id, Number(res.lastInsertRowid)); - } - - // Migrate photos - for (const p of oldPhotos) { - const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null; - if (!newEntryId || !p.file_path) continue; - - db.prepare( - ` - INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - newEntryId, - p.file_path, - p.thumbnail_path || null, - p.caption || null, - p.sort_order || 0, - p.width || null, - p.height || null, - p.created_at ? new Date(p.created_at).getTime() : ts, - ); - } - - console.log( - `[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`, - ); - } - }, - // Migration 88: Journey photos — provider support (Immich/Synology) - () => { - try { - db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - // file_path was NOT NULL — recreate table to make it nullable - const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get(); - if (hasProvider) { - // Already has the column, just ensure file_path is nullable by recreating - try { - db.exec(` - CREATE TABLE journey_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - provider TEXT NOT NULL DEFAULT 'local', - asset_id TEXT, - owner_id INTEGER REFERENCES users(id), - file_path TEXT, - thumbnail_path TEXT, - caption TEXT, - sort_order INTEGER DEFAULT 0, - width INTEGER, - height INTEGER, - shared INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ); - INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos; - DROP TABLE journey_photos; - ALTER TABLE journey_photos_new RENAME TO journey_photos; - CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id); - `); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - }, - // Migration 89: Journey cover image - () => { - try { - db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 90: Pros/Cons for journey entries - () => { - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 91: Journey share tokens - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS journey_share_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - created_by INTEGER NOT NULL, - share_timeline INTEGER DEFAULT 1, - share_gallery INTEGER DEFAULT 1, - share_map INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (created_by) REFERENCES users(id) - ) - `); - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)'); - }, - // Migration: Vacay week_start setting (0=Sunday, 1=Monday default) - () => { - try { - db.exec('ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: Unified Photo Provider Abstraction Layer (#584) - // Central trek_photos registry; trip_photos + journey_photos reference via photo_id - () => { - // 1. Create the central photo registry - db.exec(` - CREATE TABLE IF NOT EXISTS trek_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - asset_id TEXT, - owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - file_path TEXT, - thumbnail_path TEXT, - width INTEGER, - height INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL', - ); - db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)'); - - // 2. Migrate trip_photos → trek_photos + photo_id FK - const tripPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'") - .get(); - if (tripPhotosExists) { - // Detect schema variant: old (immich_asset_id) vs new (asset_id + provider) - const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const tpColNames = new Set(tpCols.map((c) => c.name)); - const hasProvider = tpColNames.has('provider'); - const assetCol = tpColNames.has('asset_id') - ? 'asset_id' - : tpColNames.has('immich_asset_id') - ? 'immich_asset_id' - : null; - const hasAlbumLink = tpColNames.has('album_link_id'); - - if (assetCol) { - const providerExpr = hasProvider ? 'provider' : "'immich'"; - // Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider - const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'"; - const sharedExpr = tpColNames.has('shared') ? 'shared' : '1'; - const addedAtExpr = tpColNames.has('added_at') - ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' - : 'CURRENT_TIMESTAMP'; - const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL'; - - // Insert existing trip photo references into trek_photos - db.exec(` - INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) - SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr} - FROM trip_photos - WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != '' - `); - - // Recreate trip_photos with photo_id FK - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - shared INTEGER NOT NULL DEFAULT 1, - album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, photo_id) - ) - `); - db.exec(` - INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) - SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr} - FROM trip_photos tp - JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id - `); - } else { - // No asset column at all — just recreate empty - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - shared INTEGER NOT NULL DEFAULT 1, - album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, photo_id) - ) - `); - } - db.exec('DROP TABLE trip_photos'); - db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)'); - } - - // 3. Migrate journey_photos → trek_photos + photo_id FK - const journeyPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'") - .get(); - if (journeyPhotosExists) { - // Insert provider-based journey photos into trek_photos - db.exec(` - INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) - SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at - FROM journey_photos - WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' - `); - // Insert local journey photos into trek_photos (each is unique) - db.exec(` - INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) - SELECT 'local', file_path, thumbnail_path, width, height, created_at - FROM journey_photos - WHERE provider = 'local' AND file_path IS NOT NULL - `); - - // Recreate journey_photos with photo_id FK - db.exec(` - CREATE TABLE journey_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - caption TEXT, - sort_order INTEGER DEFAULT 0, - shared INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ) - `); - // Migrate provider photos - db.exec(` - INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) - SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at - FROM journey_photos jp - JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id - WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL - `); - // Migrate local photos (match by file_path) - db.exec(` - INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) - SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at - FROM journey_photos jp - JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path - WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL - `); - db.exec('DROP TABLE journey_photos'); - db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); - } - }, - // Migration 99: hide_skeletons per-user setting on journey_contributors - () => { - try { - db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 100: Idempotency keys for offline mutation replay - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS idempotency_keys ( - key TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - method TEXT NOT NULL, - path TEXT NOT NULL, - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - PRIMARY KEY (key, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); - `); - }, - - // Migration 101: Enable naver_list_import by default - () => { - db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run(); - }, - - // Migration 102: Add check_in_end column for check-in time ranges - () => { - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 103: System notices — user tracking columns + dismissals table - () => { - db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); - db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`); - db.exec(` - CREATE TABLE IF NOT EXISTS user_notice_dismissals ( - user_id INTEGER NOT NULL, - notice_id TEXT NOT NULL, - dismissed_at INTEGER NOT NULL, - PRIMARY KEY (user_id, notice_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - }, - // Migration 104: Passphrase support for Synology shared-album links (#689) - () => { - try { - db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 105: Persistent Google place photo disk cache registry - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS google_place_photo_meta ( - place_id TEXT PRIMARY KEY, - attribution TEXT, - fetched_at INTEGER NOT NULL, - error_at INTEGER - ) - `); - }, - // Migration 106: Persistent Place Details row cache - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_details_cache ( - place_id TEXT NOT NULL, - lang TEXT NOT NULL DEFAULT '', - expanded INTEGER NOT NULL DEFAULT 0, - payload_json TEXT NOT NULL, - fetched_at INTEGER NOT NULL, - PRIMARY KEY (place_id, lang, expanded) - ) - `); - }, - // Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs - { - raw: () => { - db.exec(` - UPDATE places - SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', - updated_at = CURRENT_TIMESTAMP - WHERE google_place_id IS NOT NULL - AND image_url IS NOT NULL - AND image_url != '' - AND ( - (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') - OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') - ) - `); - }, - }, - // Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) - () => - db.exec(` - CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( - cache_key TEXT PRIMARY KEY, - content_type TEXT NOT NULL DEFAULT 'image/jpeg', - fetched_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at); - `), - // Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587 - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_endpoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - role TEXT NOT NULL, - sequence INTEGER NOT NULL DEFAULT 0, - name TEXT NOT NULL, - code TEXT, - lat REAL NOT NULL, - lng REAL NOT NULL, - timezone TEXT, - local_time TEXT, - local_date TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)', - ); - try { - db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 110 — link transport reservations to days via day_id / end_day_id - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - - db.exec(` - UPDATE reservations - SET day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_time, 1, 10) - LIMIT 1 - ) - WHERE type IN ('flight','train','car','cruise','bus') - AND reservation_time IS NOT NULL - AND day_id IS NULL - `); - - db.exec(` - UPDATE reservations - SET end_day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - LIMIT 1 - ) - WHERE type IN ('flight','train','car','cruise','bus') - AND reservation_end_time IS NOT NULL - AND end_day_id IS NULL - AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) - `); - }, - // Migration 111: opt-in Immich auto-upload — users column only (#730) - // Default is off — uploading to Immich must be an explicit choice, not a - // side effect of having a writable API key. - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN immich_auto_upload INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 112: expose immich auto-upload toggle in the Settings UI (#730) - // Runs after Immich provider seeding so the FK to photo_providers holds. - () => { - try { - const hasTable = db - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('photo_providers', 'photo_provider_fields')", - ) - .all() as Array<{ name: string }>; - const hasProviders = hasTable.some((t) => t.name === 'photo_providers'); - const hasFields = hasTable.some((t) => t.name === 'photo_provider_fields'); - if (hasProviders && hasFields) { - const immichRow = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich' LIMIT 1").get(); - if (immichRow) { - db.prepare( - ` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) - `, - ).run(); - } - } - } catch (err: any) { - if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err; - } - }, - // Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp - () => { - try { - db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration: password reset — add password_version for session - // invalidation, and a token table keyed by SHA-256 hash (raw tokens - // never hit the DB). - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN password_version INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - consumed_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); - CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); - `); - }, - // Migration: todo due-date reminders — track when we last sent a - // reminder for each todo so we don't spam the same notification - // every day the scheduler runs. - () => { - try { - db.exec('ALTER TABLE todo_items ADD COLUMN reminded_at DATETIME'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration: security audit batch 1 — columns + indexes required - // by several fixes bundled into one PR. - // - share_tokens.expires_at: public share links now get a 90-day - // TTL by default; existing rows stay NULL (= no expiry) to avoid - // silently breaking already-published links. - // - Missing indexes on high-cardinality query paths (see PERF-H1 - // in the audit): every listTrips() used to full-scan trips on - // user_id, and notifications/photos/reservations had similar - // gaps. - () => { - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN expires_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id); - CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id); - CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id); - CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id); - CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token); - `); - try { - // day_accommodations may have either start_day_id/end_day_id or a - // single day_id depending on how far the schema has evolved; - // build whichever index makes sense for the live columns. - const cols = db.prepare("PRAGMA table_info('day_accommodations')").all() as Array<{ name: string }>; - const names = new Set(cols.map((c) => c.name)); - if (names.has('start_day_id')) - db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)'); - if (names.has('end_day_id')) - db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)'); - } catch (err) { - // Non-fatal: day_accommodations may not exist on very old installs. - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - // notifications schema has varied; probe before indexing. - const cols = db.prepare("PRAGMA table_info('notifications')").all() as Array<{ name: string }>; - const names = new Set(cols.map((c) => c.name)); - if (names.has('target') && names.has('scope')) { - db.exec('CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)'); - } - } catch (err) { - // Non-fatal: notifications table may not exist on very old installs. - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: widen idempotency_keys primary key to (key, user_id, - // method, path). The middleware lookup was widened in the same audit - // batch so a reused X-Idempotency-Key against a different endpoint - // does not replay the cached body of an unrelated request. The old - // PK was only (key, user_id), so the `INSERT OR IGNORE` on the - // second endpoint silently skipped — the cache never stored request - // B's response and replays re-executed the handler. Rebuild the - // table with the widened PK, preserving existing rows (the old PK - // guarantees no conflicts in the new, strictly looser unique key). - () => { - const hasTable = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'idempotency_keys'") - .get(); - if (!hasTable) return; - db.exec(` - CREATE TABLE idempotency_keys_new ( - key TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - method TEXT NOT NULL, - path TEXT NOT NULL, - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - PRIMARY KEY (key, user_id, method, path) - ); - INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at) - SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys; - DROP TABLE idempotency_keys; - ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys; - CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); - `); - }, - // SEC-H6: revoke all OAuth tokens issued before audience binding was - // enforced. mcp/index.ts now unconditionally checks audience; tokens - // with audience=null would be permanently rejected by the check, so - // removing them here avoids leaving dead rows and makes the intent clear. - () => { - const hasCol = db.prepare("SELECT name FROM pragma_table_info('oauth_tokens') WHERE name = 'audience'").get(); - if (!hasCol) return; - db.prepare('DELETE FROM oauth_tokens WHERE audience IS NULL').run(); - }, - // Remove NOT NULL constraint on day_accommodations.place_id so hotel - // reservations created from the Bookings tab without a linked place can - // still persist their date range. Change ON DELETE CASCADE → SET NULL so - // deleting a place orphans the accommodation row instead of cascading. - () => { - db.exec(` - CREATE TABLE day_accommodations_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - check_in TEXT, - check_in_end TEXT, - check_out TEXT, - confirmation TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - INSERT INTO day_accommodations_new - SELECT id, trip_id, place_id, start_day_id, end_day_id, - check_in, check_in_end, check_out, confirmation, notes, created_at - FROM day_accommodations; - DROP TABLE day_accommodations; - ALTER TABLE day_accommodations_new RENAME TO day_accommodations; - CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id); - `); - }, - // Migration: null out proxy image_url entries that have no backing disk cache. - // Migrations 107 and the migration below wrote /api/maps/place-photo//bytes - // into places.image_url without actually fetching/caching the photo bytes. The - // photoService short-circuits on that prefix and hits /bytes directly → 404. - // Rows with a confirmed disk cache entry in google_place_photo_meta are left alone; - // only stale proxy URLs (never actually fetched) are cleared so the normal - // fetch-and-cache flow can repopulate them. - () => { - db.exec(` - UPDATE places - SET image_url = NULL, updated_at = CURRENT_TIMESTAMP - WHERE image_url LIKE '/api/maps/place-photo/%/bytes' - AND google_place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM google_place_photo_meta - WHERE place_id = places.google_place_id - AND error_at IS NULL - ) - `); - }, - // Migration: clear legacy Google photo URLs missed by Migration 107. - // Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use - // /place-photos/ or /places/ paths and were skipped. NULL those stale URLs - // so the normal fetch-and-cache flow repopulates image_url with a real proxy URL. - () => { - db.exec(` - UPDATE places - SET image_url = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE image_url IS NOT NULL - AND image_url != '' - AND image_url NOT LIKE '/api/maps/place-photo/%' - AND ( - image_url LIKE 'http://%googleusercontent.com/%' - OR image_url LIKE 'https://%googleusercontent.com/%' - OR image_url LIKE 'http://%places.googleapis.com/%' - OR image_url LIKE 'https://%places.googleapis.com/%' - ) - `); - }, - // Migration 121: Journey gallery refactor — decouple photo ownership from - // entries. journey_photos becomes a per-journey gallery (one row per unique - // photo per journey). A new junction table journey_entry_photos links - // gallery photos to the entries that reference them, allowing the same - // photo to appear in multiple entries without duplication. Synthetic - // wrapper entries ('Gallery', '[Trip Photos]') created by the old model - // are removed — the gallery table replaces them. - () => { - const hasOld = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get(); - const hasBackup = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'") - .get(); - if (hasOld && !hasBackup) { - db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old'); - } - - db.exec(` - CREATE TABLE IF NOT EXISTS journey_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - caption TEXT, - shared INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - provider TEXT, - asset_id TEXT, - owner_id INTEGER, - created_at INTEGER NOT NULL, - UNIQUE(journey_id, photo_id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS journey_entry_photos ( - entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE, - journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - created_at INTEGER NOT NULL, - PRIMARY KEY(entry_id, journey_photo_id) - ) - `); - - if (hasOld || hasBackup) { - // Backfill gallery: deduplicate by (journey_id, photo_id), keeping - // the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT). - db.exec(` - INSERT OR IGNORE INTO journey_photos - (journey_id, photo_id, caption, shared, sort_order, created_at) - SELECT - je.journey_id, - jpo.photo_id, - jpo.caption, - jpo.shared, - jpo.sort_order, - jpo.created_at - FROM journey_photos_old jpo - JOIN journey_entries je ON je.id = jpo.entry_id - WHERE jpo.id IN ( - SELECT MIN(jpo2.id) - FROM journey_photos_old jpo2 - JOIN journey_entries je2 ON je2.id = jpo2.entry_id - GROUP BY je2.journey_id, jpo2.photo_id - ) - `); - - // Backfill junction: one row per (entry_id, photo_id), resolved to - // the new gallery ids. - db.exec(` - INSERT OR IGNORE INTO journey_entry_photos - (entry_id, journey_photo_id, sort_order, created_at) - SELECT - jpo.entry_id, - jp.id, - jpo.sort_order, - jpo.created_at - FROM journey_photos_old jpo - JOIN journey_entries je ON je.id = jpo.entry_id - JOIN journey_photos jp - ON jp.journey_id = je.journey_id - AND jp.photo_id = jpo.photo_id - `); - - db.exec('DROP TABLE journey_photos_old'); - } - - // Remove synthetic wrapper entries replaced by the gallery model. - // ON DELETE CASCADE on journey_entry_photos cleans up junction rows. - db.prepare("DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')").run(); - - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)'); - }, - // Migration 122: Correct stale day_id / end_day_id on non-transport - // reservations. Migration 110 only backfilled transport types; tours, - // restaurants, events and "other" bookings kept a stale day_id from - // older code paths that often defaulted to the first day of the trip. - // Starting with v3.0.0 the planner renders reservations by day_id - // instead of reservation_time, so those stale rows show up on the - // wrong day. This migration nulls out day_id / end_day_id values that - // don't match the reservation's time and then backfills them from - // reservation_time / reservation_end_time. - () => { - db.exec(` - UPDATE reservations - SET day_id = NULL - WHERE reservation_time IS NOT NULL - AND day_id IS NOT NULL - AND type != 'hotel' - AND NOT EXISTS ( - SELECT 1 FROM days d - WHERE d.id = reservations.day_id - AND d.date = substr(reservations.reservation_time, 1, 10) - ) - `); - - db.exec(` - UPDATE reservations - SET end_day_id = NULL - WHERE reservation_end_time IS NOT NULL - AND end_day_id IS NOT NULL - AND type != 'hotel' - AND NOT EXISTS ( - SELECT 1 FROM days d - WHERE d.id = reservations.end_day_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - ) - `); - - db.exec(` - UPDATE reservations - SET day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_time, 1, 10) - LIMIT 1 - ) - WHERE type != 'hotel' - AND reservation_time IS NOT NULL - AND day_id IS NULL - `); - - db.exec(` - UPDATE reservations - SET end_day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - LIMIT 1 - ) - WHERE type != 'hotel' - AND reservation_end_time IS NOT NULL - AND end_day_id IS NULL - AND substr(reservations.reservation_end_time, 1, 10) - != substr(reservations.reservation_time, 1, 10) - `); - }, - // #846: make sort_order authoritative within a day. Previous ORDER BY put - // entry_time before sort_order, silently ignoring reorder clicks when two - // same-date entries had different times. Backfill renumbers using the old - // effective key (entry_time ASC, id ASC) so existing journeys retain their - // current visual order. - () => { - db.exec(` - WITH ranked AS ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY journey_id, entry_date - ORDER BY entry_time ASC, id ASC - ) - 1 AS rn - FROM journey_entries - ) - UPDATE journey_entries - SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' + - 'ON journey_entries(journey_id, entry_date, sort_order)', - ); - }, - // Swap inverted start_day_id/end_day_id pairs in day_accommodations caused - // by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs - // instead of positional order on trips with non-monotonic day ID layouts. - () => { - db.exec(` - UPDATE day_accommodations - SET start_day_id = end_day_id, end_day_id = start_day_id - WHERE (SELECT day_number FROM days WHERE id = start_day_id) - > (SELECT day_number FROM days WHERE id = end_day_id) - `); - }, - // prepare migration to nest + typeorm - () => { - db.exec( - `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`, - ); - db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`); - db.prepare("INSERT INTO app_settings (key, value) VALUES ('app_version', ?)").run( - readEnv().app.appVersion || '3.0.14', - ); - }, - // trim leading/trailing whitespace from stored usernames and emails - () => { - const hadCollision = trimUserWhitespace(db); - if (hadCollision) { - db.prepare( - "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')", - ).run(); - } - }, - () => { - db.exec( - `CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT,version INTEGER NOT NULL)`, - ); - db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); - db.exec(`DROP TABLE schema_version`); - db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`); - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'app_version'").run( - readEnv().app.appVersion || '3.0.15', - ); - }, - // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential - // clients to skip the browser consent flow entirely and obtain tokens directly - // via client_id + client_secret. Flag is immutable after creation so existing - // authorization-code clients are not silently upgraded. - () => { - try { - db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Drop stale atlas cache rows for territories that used to resolve to their - // surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy, - // etc.) before their own bounding boxes existed. The next atlas stats request - // re-resolves any place inside these boxes with the corrected country code. - () => { - const enclaveBoxes: [number, number, number, number][] = [ - [113.83, 22.15, 114.43, 22.56], // HK - [113.53, 22.1, 113.6, 22.21], // MO - [12.4, 43.89, 12.52, 43.99], // SM - [12.44, 41.9, 12.46, 41.91], // VA - [7.4, 43.72, 7.44, 43.75], // MC - [9.47, 47.05, 9.64, 47.27], // LI - [-5.36, 36.11, -5.33, 36.16], // GI - [-67.3, 17.88, -65.22, 18.53], // PR - ]; - try { - const del = db.prepare( - `DELETE FROM place_regions WHERE place_id IN ( - SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? - )`, - ); - for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) { - del.run(minLat, maxLat, minLng, maxLng); - } - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - }, - // Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively - // and without touching existing rows: - // - per-expense currency + exchange_rate, so an expense can be entered in a - // foreign currency and converted to the trip base currency (NULL currency = - // base currency; rate 1.0). Closes the multi-currency request (#551). - // - budget_item_payers: several people can each have paid part of one expense - // (amounts in the expense currency), replacing the single paid_by_user_id. - // - budget_settlements: persisted "X paid Y" transfers so the settle-up - // history (with undo) is shared across all trip members. - // The equal-split participants stay in budget_item_members. The single legacy - // payer is backfilled into budget_item_payers as one payer covering the total. - () => { - try { - db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - - db.exec(` - CREATE TABLE IF NOT EXISTS budget_item_payers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - amount REAL NOT NULL DEFAULT 0, - UNIQUE(budget_item_id, user_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)'); - - db.exec(` - CREATE TABLE IF NOT EXISTS budget_settlements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - amount REAL NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_by_user_id INTEGER REFERENCES users(id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)'); - - // Backfill the legacy single payer: that person paid the full total of the - // expense, in the (base) currency the existing amount was already stored in. - try { - db.exec(` - INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) - SELECT id, paid_by_user_id, total_price - FROM budget_items - WHERE paid_by_user_id IS NOT NULL - `); - } catch (err: any) { - if (!err.message?.includes('no such column')) throw err; - } - }, - // Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This - // is a display rename only — the addon id, tables, permissions and MCP tools - // all stay 'budget'. Scoped to the default name so a customised one is kept. - () => { - db.prepare( - "UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'", - ).run(); - }, - // WebAuthn / passkey support: per-user credentials + single-use login - // challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are - // untouched; both tables also live in schema.ts for fresh installs. - () => - db.exec(` - CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - credential_id TEXT NOT NULL UNIQUE, - public_key BLOB NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - transports TEXT, - device_type TEXT, - backed_up INTEGER NOT NULL DEFAULT 0, - name TEXT, - aaguid TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); - CREATE TABLE IF NOT EXISTS webauthn_challenges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - challenge TEXT NOT NULL UNIQUE, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - expires_at INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); - `), - // Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national - // regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; some no - // longer match any polygon in the new bundle and would stop highlighting. Reconcile - // every row against the ACTUAL shipped admin-1 bundle so this covers *all* countries, - // not just one hand-listed reform: - // 1. code still present in the new bundle → leave it (already correct); - // 2. else a region in the same country shares → adopt that region's code+name - // the stored region_name (case-insensitive) (handles code re-spellings, e.g. - // ES-AN → ES_AND, names unchanged); - // 3. else a curated merge crosswalk maps it → adopt the merged region (handles - // (region absorbed into a *renamed* one) reforms where the name changed, - // which step 2 cannot catch); - // 4. else → leave as-is (cannot be resolved; the client's name fallback may still - // highlight it, and nothing is destroyed). - // Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold only - // ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` is free - // text we must not auto-rewrite, and `place_regions` is a re-derivable Nominatim cache. - () => { - type Row = { id: number; region_code: string; region_name: string; country_code: string }; - const rows = db.prepare('SELECT id, region_code, region_name, country_code FROM visited_regions').all() as Row[]; - if (rows.length === 0) return; // nothing marked → skip the bundle read entirely - - // Index the shipped admin-1 bundle: valid codes, name→code per country, code→name. - // __dirname resolves ../../assets under both dist (dist/db) and tests (src/db). - let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[]; - try { - const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); - features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; - } catch { - features = []; // bundle missing → degrade to the curated crosswalk below - } - const validCodes = new Set(); - const nameToCode = new Map(); // `${A2}|${nameLower}` → code - const codeToName = new Map(); - for (const f of features) { - const a2 = (f.properties?.iso_a2 || '').toUpperCase(); - const code = f.properties?.iso_3166_2 || ''; - const name = f.properties?.name || ''; - if (!code) continue; - validCodes.add(code); - if (!codeToName.has(code)) codeToName.set(code, name); - if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code); - } - - // Curated crosswalk for regions absorbed into a *renamed* successor (step 2 can't - // match these because the name changed). Norway's 2018/2020 reforms; extend as the - // pinned geoBoundaries dataset gains further reforms. - const MERGE_CROSSWALK: Record = { - 'NO-04': 'NO-34', - 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet - 'NO-12': 'NO-46', - 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland - 'NO-09': 'NO-42', - 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder - 'NO-01': 'NO-30', - 'NO-02': 'NO-30', - 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken - 'NO-07': 'NO-38', - 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark - 'NO-19': 'NO-54', - 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark - 'NO-16': 'NO-50', - 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag - }; - - const resolve = (row: Row): string | null => { - if (validCodes.has(row.region_code)) return null; // already valid - const a2 = (row.country_code || '').toUpperCase(); - const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`); - if (byName) return byName; - const merged = MERGE_CROSSWALK[row.region_code]; - // Only trust the crosswalk target if it actually exists in the bundle (or the - // bundle was unreadable, in which case we apply the curated map blindly). - if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged; - return null; - }; - - const update = db.prepare('UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?'); - const del = db.prepare('DELETE FROM visited_regions WHERE id = ?'); - for (const row of rows) { - const newCode = resolve(row); - if (!newCode || newCode === row.region_code) continue; - const newName = codeToName.get(newCode) || row.region_name; - update.run(newCode, newName, row.id); - // UNIQUE(user_id, region_code): if the user already had the target code the - // UPDATE was IGNORED and this row still carries the old code → drop the duplicate. - const after = db.prepare('SELECT region_code FROM visited_regions WHERE id = ?').get(row.id) as - | { region_code: string } - | undefined; - if (after && after.region_code === row.region_code) del.run(row.id); - } - }, - () => { - // AirTrail integration addon — disabled by default (opt-in). Per-user connection - // lives in Settings → Integrations; this row is only the admin-level global toggle. - try { - db.prepare( - 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run( - 'airtrail', - 'AirTrail', - 'Sync flights from your self-hosted AirTrail instance', - 'integration', - 'Plane', - 0, - 14, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - // AirTrail per-user connection (mirrors the Immich integration columns). - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_api_key TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // AirTrail flight linkage on reservations (#214) — lets a TREK transport - // remember its AirTrail origin so the two-way sync can match + update it. - // sync_enabled flips to 0 when the AirTrail flight is deleted (row kept). - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_source TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_id TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_synced_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_hash TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - // NULLs compare distinct in SQLite, so non-linked reservations don't collide. - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)', - ); - }, - () => { - // Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default - // off: AirTrail is the source of truth and TREK never writes unless asked. - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Store Google Maps feature IDs separately from real Google Places API IDs. - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Remember the app version a notice was dismissed at, so per-version recurring - // notices (e.g. the thank-you) re-appear on the next install/upgrade. - () => { - try { - db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds - () => { - try { - db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL'); - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL'); - }, - // Optimistic-concurrency token for offline conflict detection (#1135). - // packing_items had only created_at, so an offline edit could not be checked - // against a concurrent server change. SQLite forbids a non-constant DEFAULT on - // ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows - // set it explicitly (packingService). Additive: a request without the - // X-Base-Updated-At header keeps the old last-write-wins behaviour. - () => { - try { - db.exec('ALTER TABLE packing_items ADD COLUMN updated_at DATETIME'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL'); - }, - // Video support (#823): the trek_photos registry held only images. media_type - // discriminates image vs video so the gallery, lightbox and provider proxy can - // branch; duration_ms is optional metadata for the player. Additive — existing - // rows default to 'image'. - () => { - for (const stmt of [ - "ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'", - 'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER', - ]) { - try { - db.exec(stmt); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - } - }, - // Dedicated booking URL (#935) — users previously stuffed links into notes. - // Additive nullable TEXT; existing rows default to NULL. - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Private packing items (#858): an item can be hidden from other trip members. - // is_private toggles the visibility; owner_id records who it belongs to so the - // listing can show it only to them. owner_id is NULL on legacy rows (shared). - () => { - for (const stmt of [ - 'ALTER TABLE packing_items ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0', - 'ALTER TABLE packing_items ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL', - ]) { - try { - db.exec(stmt); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - } - }, - // Guest members (#1362): people added to a trip without an account. A guest is a - // users row flagged is_guest=1 (no usable credentials) joined into trip_members, - // so it's assignable everywhere a member is — but must never authenticate or show - // up in the global user directory. The flag is the discriminator for those guards. - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0, - // every existing item — non-breaking), Personal (is_private=1, owner only) or - // Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer". - // Contributors are extra people who said "I can bring that too" on a Common item - // (status 'pending' until the owner accepts). - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS packing_item_recipients ( - item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (item_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id); - CREATE TABLE IF NOT EXISTS packing_item_contributors ( - item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'accepted', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (item_id, user_id) - ); - `); - }, - // Migration 150: Collections addon — personal place library (#1081). - // Multi-list + per-place status (idea/want/visited) + fusion sharing. - // (150 = migrations.length of this array after appending — the runner uses - // migrations.length, not this comment; the label is cosmetic.) - () => { - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) - VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) - `, - ).run(); - - db.exec(` - CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT 'Bookmark', - cover_image TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - osm_id TEXT, - website TEXT, - phone TEXT, - status TEXT NOT NULL DEFAULT 'idea', - source_trip_id INTEGER, - source_place_id INTEGER, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_tags ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, tag_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); - `); - }, - - // Migration 151: user-added links on collections + saved places (JSON text) - () => { - try { db.exec('ALTER TABLE collections ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - try { db.exec('ALTER TABLE collection_places ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 152: per-member permission role on a shared list. Existing - // accepted members default to 'editor' so nothing regresses. - () => { - try { db.exec("ALTER TABLE collection_members ADD COLUMN role TEXT NOT NULL DEFAULT 'editor'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 153: per-trip invite links (#1143). One rotating token per trip; - // a logged-in existing user who opens the link joins the trip as a member. - // Deleting the trip drops the token (CASCADE); the creator is nulled if their - // account is removed so the link keeps working. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS trip_invite_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, - token TEXT UNIQUE NOT NULL, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - expires_at TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token); - `); - }, - // Migration 154: optional trip binding on an admin invite link (#1402). A user - // who REGISTERS via the link is auto-added to the trip. Nullable for backward - // compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. - () => { - try { db.exec('ALTER TABLE invite_tokens ADD COLUMN trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 155: plugin system scaffold (#plugins). A plugin is a row here; - // its code lives on the /plugins volume and (once the runtime lands) runs in - // an isolated child process. This migration only lays down the registry - // tables — nothing executes yet. Own data lives in a per-plugin sqlite file - // under /plugins-data, never in these tables. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugins ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - type TEXT NOT NULL DEFAULT 'integration', - icon TEXT DEFAULT 'Blocks', - version TEXT, - api_version INTEGER DEFAULT 1, - min_trek_version TEXT, - permissions TEXT DEFAULT '[]', - granted_permissions TEXT DEFAULT '[]', - status TEXT NOT NULL DEFAULT 'inactive', - config TEXT DEFAULT '{}', - source_repo TEXT, - source_commit TEXT, - sha256 TEXT, - crash_count INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - reviewed_at TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS plugin_meta_migrations ( - plugin_id TEXT NOT NULL, - migration_id TEXT NOT NULL, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (plugin_id, migration_id) - ); - CREATE TABLE IF NOT EXISTS plugin_error_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - ts DATETIME DEFAULT CURRENT_TIMESTAMP, - level TEXT NOT NULL DEFAULT 'error', - message TEXT, - stack TEXT - ); - CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts); - CREATE TABLE IF NOT EXISTS plugin_settings_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - field_key TEXT NOT NULL, - label TEXT, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER NOT NULL DEFAULT 0, - secret INTEGER NOT NULL DEFAULT 0, - scope TEXT NOT NULL DEFAULT 'instance', - options TEXT, - oauth_config TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - UNIQUE (plugin_id, field_key) - ); - `); - }, - // Migration 156: separate the admin's ON/OFF intent (`enabled`) from the - // runtime health (`status`). A crash used to flip status to 'error', which - // erased the "keep it on" intent, so the plugin never rebooted after a deploy. - // Boot now retries every enabled plugin regardless of last status. - () => { - try { - db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;"); - // Anything not explicitly deactivated was meant to be on ('inactive' is the - // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting). - db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';"); - } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 157: plugin capabilities (from trek-plugin.json) — the client - // needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay - // on the boarding-pass bar instead of the dashboard sidebar). - () => { - try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4). - // Set on first install of a signed plugin; a later install whose registry key - // differs is a hard stop (author change / key rotation / attack) unless an - // admin re-trusts. NULL for unsigned plugins (signing is opt-in). - () => { - try { db.exec("ALTER TABLE plugins ADD COLUMN author_pubkey TEXT;"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 159: hash-chained capability audit log (#plugins, L1 hardening). - // Every host-mediated capability call the plugin makes is recorded at the RPC - // boundary (where the plugin provably can't reach) with the host-bound acting - // user and a per-plugin hash chain, so wide data grants stay attributable + - // tamper-evident + user-visible. - () => { - try { - db.exec(`CREATE TABLE IF NOT EXISTS plugin_capability_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - acting_user_id INTEGER, - method TEXT NOT NULL, - resource TEXT, - code TEXT NOT NULL, - ts TEXT NOT NULL DEFAULT (datetime('now')), - prev_hash TEXT, - hash TEXT NOT NULL - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id);'); - } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 160: per-collection custom labels (#collections). Each list owns - // its own label set (unlike the instance-wide `tags` table), and a place can - // carry several labels. Used for grouping + filtering places within a list. - () => { - db.exec(`CREATE TABLE IF NOT EXISTS collection_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - db.exec(`CREATE TABLE IF NOT EXISTS collection_place_labels ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, label_id) - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);'); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);'); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);'); - }, - // Migration 161: plugin-owned metadata on core entities (#1429). A namespaced - // key/value store so a plugin can attach data to a trip/place/day WITHOUT - // forking the core schema. One row per (plugin, entity, key); a plugin only - // ever sees its own rows. entity_type is polymorphic (no cross-table FK), so - // rows are purged by plugin_id on uninstall; entity deletes leave harmless - // orphans that the plugin's own reads never surface. - () => { - db.exec(`CREATE TABLE IF NOT EXISTS plugin_entity_metadata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, entity_type, entity_id, key) - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id);'); - }, - - // Freeze the FX rate on settle-up transfers too (#1445). budget_settlements - // stored only a bare `amount` in whatever display currency the payer was - // viewing, so a later live-rate drift re-opened an already-settled position - // with a few-cent residual. Capture the display `currency` and the rate frozen - // at settle time (units of that currency per 1 trip currency), mirroring the - // budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on - // live rates until re-edited. - () => { - try { - db.exec('ALTER TABLE budget_settlements ADD COLUMN currency TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE budget_settlements ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - - // #1446: guests are per-trip people, but their display name lived in the globally - // UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to - // "Jake 2". Add a non-unique display_name; new guests store the human name here and - // get a uuid-based username that is never shown (the member views COALESCE to it). - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN display_name TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - - // Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare - // `requiredAddons` (addon ids that must be enabled to activate) and - // `pluginDependencies` ({id, version-range} of other plugins that must be - // installed + satisfied). Stored as one JSON blob and populated by the - // discovery upsert on every install path. Legacy rows default to '{}' (no deps). - () => { - try { - db.exec("ALTER TABLE plugins ADD COLUMN dependencies TEXT NOT NULL DEFAULT '{}'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Per-user plugin settings (#plugins). A plugin can declare `scope:'user'` - // settings fields (e.g. an API key or a personal preference); each USER stores - // their own values here, separate from the admin-owned instance `plugins.config`. - // Secrets are encrypted with the same apiKeyCrypto as instance secrets and are - // never echoed back to the client (masked). Runtime reads the acting user's row. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_user_config ( - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - config TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (plugin_id, user_id) - ); - `); - }, - // Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a - // third-party service; the HOST runs authorize->callback->token->refresh with - // PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens - // are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_oauth_tokens ( - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - access_token TEXT, - refresh_token TEXT, - expires_at INTEGER, - scope TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (plugin_id, user_id) - ); - CREATE TABLE IF NOT EXISTS plugin_oauth_state ( - state TEXT PRIMARY KEY, - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - verifier TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - `); - }, - // Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run` - // grant can schedule a userless callback to fire at a future time (once, or - // recurring), surviving server restarts because the entry lives here. Same risk - // class as cron jobs (no user, no trip reads, own db + declared egress only) — - // so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes - // scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - name TEXT NOT NULL, - due_at INTEGER NOT NULL, - payload TEXT NOT NULL DEFAULT 'null', - every_ms INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, name) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at);'); - }, - // Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every - // installed plugin holding `hook:user-data` gets a pending row here so its own - // deleteUserData handler runs even if the plugin was offline at delete time — - // erasure must not be lost across a restart, so it is persisted (unlike the - // best-effort event buffer). The row is removed once the plugin acknowledges, - // and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id) - // makes re-enqueue idempotent. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id);'); - }, - - // Tombstones for Atlas countries the user has explicitly removed (#1490). - // Atlas derives visited countries from trip places and transport endpoints on every - // request, so those countries have no row to delete — "Remove" deleted from - // visited_countries (which never had the row), the client hid it optimistically, and - // the next getStats re-derived it. Recording the removal here lets getStats suppress - // a derived country. Re-marking a country deletes its tombstone. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS hidden_countries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, country_code) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id);'); - }, - - // Operator-supplied egress hosts for a plugin (#plugins). - // A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin - // that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's - // hostname — so a community plugin could serve nobody. These rows let the ADMIN add - // hosts post-install; the runtime unions them into the child's allow-list at spawn. - // Consent stays with the admin (never the end user), exactly as for manifest egress. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - host TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, host) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id);'); - // Whether the plugin DECLARED that it needs operator-supplied hosts. Only such a - // plugin may have hosts added — an admin can never widen egress for a plugin that - // never asked for it, so the install-time consent still bounds what's possible. - const columns = db.prepare("PRAGMA table_info('plugins')").all() as Array<{ name: string }>; - if (!columns.some((c) => c.name === 'operator_egress')) { - db.exec('ALTER TABLE plugins ADD COLUMN operator_egress INTEGER NOT NULL DEFAULT 0;'); - } - }, - - // Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). - // Descriptors only — the handler lives in the plugin's code and is invoked host-side - // with the CLICKING user bound, so it can read that user's own settings. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_actions ( - plugin_id TEXT NOT NULL, - action_key TEXT NOT NULL, - label TEXT NOT NULL, - hint TEXT, - danger INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (plugin_id, action_key) - ); - `); - }, - - // Why a plugin's update was REFUSED by the signature check (#plugins). A refused - // update leaves a working plugin pinned at its old version — previously the reason - // lived only in a transient toast, so the plugin quietly stopped updating and the - // admin had to re-attempt an update to rediscover why. Record it instead. - // - // `update_block_version` is the registry version that was refused: once the registry - // offers something NEWER, the block describes an artifact nobody is being offered - // anymore, so it reads as stale and the admin can just re-attempt (the next install - // re-verifies and either succeeds or re-blocks with fresh values). Deliberately no - // `status = 'error'` — the plugin still runs fine on its old code. - () => { - for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) { - try { - db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - }, - - // The semver RANGE of TREK versions a plugin declares it supports (its manifest's - // `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the - // lower bound, so it cannot express "stops working at 4.0" — which is precisely the - // case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin - // installed before this column existed has no range recorded, and the gate refuses to - // activate it rather than guessing (see TREK_VERSION_UNKNOWN). - () => { - try { - db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - - // `place_regions` is a re-derivable Nominatim cache, only ever populated for a place ID - // that isn't already cached — so a wrong row, once written, was permanent. Region - // resolution now resolves a place's lat/lng directly against the bundled admin1 polygons - // (the same ones the client renders) instead of trusting Nominatim's address level, which - // could name a subdivision level the bundle doesn't carry (Barcelona's ES-B province vs - // the bundle's ES-CT autonomous community) and never highlight. That fix only helps - // places re-resolved after it, so clear the cache once and let every place re-resolve on - // the next Atlas load. The country_code stored alongside is cleared too, which also drops - // the old wrong-country rows a US-state-abbreviation address used to produce. - () => { - try { - db.exec('DELETE FROM place_regions'); - } catch (err) { - // place_regions is created by an earlier migration; tolerate its absence on an - // unusual partial DB rather than aborting startup. - if (!(err instanceof Error) || !err.message.includes('no such table')) throw err; - } - }, - - // Tombstones for Atlas regions the user has explicitly removed — the region-level - // counterpart to hidden_countries above (#1490). A visited region is normally derived - // fresh from place_regions/visited_regions on every request, so "removing" it has - // nothing to delete; recording it here lets getVisitedRegions suppress a derived region - // the same way getStats already suppresses a derived country. Unlike the country-level - // tombstone (originally only reachable for a manually-marked or zero-count country), - // this also covers a region derived from real place data — e.g. one that ended up on - // the wrong side of a border-simplification gap and the user just wants gone. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS hidden_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - region_code TEXT NOT NULL, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, region_code) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id);'); - }, - // Half vacation days (#552): a vacay entry can now count as a full day (1) or - // a half day (0.5) toward the entitlement. Existing entries default to a full - // day, so the entitlement maths are unchanged for everyone already using vacay. - // Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. - () => { - const hasFraction = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'fraction'").get(); - if (!hasFraction) db.exec('ALTER TABLE vacay_entries ADD COLUMN fraction REAL NOT NULL DEFAULT 1'); - }, - // Read-only vacay calendar sharing (#444/#667): a user can let other users - // view their vacation calendar without fusing plans. owner_id is the sharing - // user, user_id the viewer; hidden lets the viewer hide the overlay without - // removing the share. Follows the person, not the plan, so it survives - // fusion and dissolution. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS vacay_shares ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - hidden INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (owner_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id);'); - }, - // Collaborative place ratings (#1435): every trip member can rate a trip - // place 1-5, every collection member a saved place; the displayed value is - // the average. One row per user and place, mirroring collab_poll_votes. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(place_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id);'); - db.exec(` - CREATE TABLE IF NOT EXISTS collection_place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_place_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id);'); - }, - // Per-segment travel mode (#1281): the day-plan route can use a different - // transport mode for each leg. leg_transport_mode on an assignment is the mode - // of the leg LEAVING that stop (NULL = inherit the day default); days gains a - // persisted default_transport_mode so the whole-day choice survives a reload. - // Both nullable → existing itineraries keep today's single-mode behaviour. - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN leg_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE days ADD COLUMN default_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // School holidays are a visual Vacay calendar layer. Keep them separate from - // public holidays so applyHolidayCalendars never removes vacation entries for - // school-break dates. Appended LAST: this branch was cut before #1435/#1281 - // landed on dev, so an existing DB is already past their slots — only a - // trailing migration re-runs on upgrade and actually adds these columns. - () => { - const planCols = db.prepare("PRAGMA table_info('vacay_plans')").all() as Array<{ name: string }>; - if (!planCols.some(col => col.name === 'school_holidays_enabled')) { - db.exec('ALTER TABLE vacay_plans ADD COLUMN school_holidays_enabled INTEGER DEFAULT 0'); - } - const calendarCols = db.prepare("PRAGMA table_info('vacay_holiday_calendars')").all() as Array<{ name: string }>; - if (!calendarCols.some(col => col.name === 'type')) { - db.exec("ALTER TABLE vacay_holiday_calendars ADD COLUMN type TEXT NOT NULL DEFAULT 'public_holiday'"); - } - }, - // #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_travelers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(reservation_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id); - CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id); - `); - }, - // Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward - // the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime - // offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5. - () => { - const hasKind = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'kind'").get(); - if (!hasKind) db.exec("ALTER TABLE vacay_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'vacation'"); - }, - // Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps - // the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed - // month/day; 'anniversary' starts on the month/day of the hire date. The year - // integer still names a period; the service resolves it to a [start,end) range. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS vacay_user_settings ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - year_type TEXT NOT NULL DEFAULT 'calendar', - year_start_month INTEGER NOT NULL DEFAULT 1, - year_start_day INTEGER NOT NULL DEFAULT 1, - hire_date TEXT - ); - `); - }, - // Manual GPX track colour (#776): imported tracks all render in the same blue - // because the importer never assigns a category, so several walks in the same - // area are indistinguishable. NULL keeps the old behaviour (category colour, - // then the #3b82f6 fallback) for every existing row. - () => { - const hasRouteColor = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'route_color'").get(); - if (!hasRouteColor) db.exec('ALTER TABLE places ADD COLUMN route_color TEXT'); - }, - - // Backfill the three places feature toggles before their read flips from - // fail-open (`value !== 'false'`) to fail-closed (`value === 'true'`), so - // existing installs that never touched the admin switches keep the features - // they have today. A row that already says 'false' is left alone. - () => { - const insert = db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, 'true')"); - for (const key of ['places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled']) { - insert.run(key); - } - }, - - // Free the `note` column on budget items for its actual purpose (#1658). - // The costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which - // means an expense split by receipt can never carry a written note, and - // saving an expense any other way wipes whatever was typed in the budget - // table. The receipt moves to its own column and note becomes text again. - // GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note - // starting "ticketjson:" would be chopped up and its text dropped. - () => { - const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get(); - if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT'); - db.exec(` - UPDATE budget_items - SET ticket_json = substr(note, 12), note = NULL - WHERE note GLOB 'TICKETJSON:*' - `); - }, - - // Note colours (#1629). A day note is a label as much as a reminder — "watch - // out", "must see", "already booked" — and a wall of identical grey cards - // makes that impossible to see at a glance. NULL keeps the neutral card - // every existing note has today. - () => { - const hasColor = db.prepare("SELECT 1 FROM pragma_table_info('day_notes') WHERE name = 'color'").get(); - if (!hasColor) db.exec('ALTER TABLE day_notes ADD COLUMN color TEXT'); - }, - - // Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place - // (booking arrival, morning hotel) stores its mode on the DESTINATION stop. - // NULL = inherit the day default. INERT whenever the previous timeline element - // is itself a place (that place's outgoing leg_transport_mode wins). - // Appended LAST: the array is index-addressed against schema_version, so a slot - // inserted anywhere above this line is simply skipped on every existing database. - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN incoming_leg_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // #1298 — an expense can hang off a place, exactly as it already hangs off a - // reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths - // take the linked expense with the place themselves, so the constraint is a - // backstop for anything that removes a place without going through them. - // Appended LAST: the array is index-addressed against schema_version, so a - // slot inserted above this line never runs on an existing database. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('budget_items')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'place_id')) { - db.exec('ALTER TABLE budget_items ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL'); - } - }, - // A reservation_day_positions row only makes sense when its reservation and - // its day are on the same trip. The table carries no trip_id and its two - // foreign keys only ask that the ids exist, so pairs that never belonged - // together could accumulate; the writer refuses them now, and this clears - // whatever an older build let through. This slot keeps the index it shipped - // with on dev — the array is index-addressed against schema_version. - () => { - db.exec(` - DELETE FROM reservation_day_positions - WHERE rowid IN ( - SELECT rdp.rowid - FROM reservation_day_positions rdp - JOIN reservations r ON r.id = rdp.reservation_id - JOIN days d ON d.id = rdp.day_id - WHERE d.trip_id <> r.trip_id - ) - `); - }, - // #1939 — the Google Places and Unsplash keys are instance configuration and - // now resolve out of app_settings (nest/settings/instance-api-keys.ts). This - // carries the value the old resolver would have handed out — the lowest-id - // admin who has one — into that row, so an install that upgrades keeps the - // key it was searching with instead of falling back to OpenStreetMap. - // Only where that key already was the whole install's, though. The resolver - // reads the instance row before the caller's own column, so promoting a - // personal key while a second row still holds one would move that member - // onto a stranger's key and a stranger's bill without anyone saying so. As - // soon as another row has a value for the same column nothing is written: - // everybody keeps resolving what they resolved before, and a member with no - // key of their own searches via OpenStreetMap until an admin saves the - // instance key once in the panel. Handing a key to the whole instance is the - // admin's call, not a migration's. - // The users columns are left alone: they are still the per-user fallback, - // and nothing here can lose a value. Stored blobs are copied verbatim; both - // sides use the same apiKeyCrypto format, legacy plaintext included. - // Appended LAST — the array is index-addressed against schema_version. - () => { - const upsert = db.prepare( - `INSERT INTO app_settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ); - for (const column of ['maps_api_key', 'unsplash_api_key'] as const) { - const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as - | { value: string | null } - | undefined; - if (existing?.value) continue; - const row = db - .prepare( - `SELECT id, ${column} AS value FROM users - WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != '' - ORDER BY id ASC LIMIT 1` - ) - .get() as { id: number; value: string } | undefined; - if (!row?.value) continue; - const otherHolder = db - .prepare( - `SELECT 1 FROM users - WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1` - ) - .get(row.id); - if (otherHolder) continue; - upsert.run(column, row.value); - } - }, - // Capture metadata on the photo itself (#1614): when it was taken and where. - // Both are needed before photos can sit on the Journey map by their own - // coordinates, and before a gallery can be ordered by when a picture was - // taken rather than when it happened to be added. Nullable throughout — - // most providers answer with neither, and a photo without them is normal. - // Guarded so re-running the migration tail is a no-op. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>; - const has = (name: string) => cols.some(c => c.name === name); - if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT'); - if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL'); - if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL'); - // Answering "which photos of this journey have coordinates" without a scan. - db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL'); - }, - // A journey shared while the trip is still running reads like a blog, and a - // blog puts the newest entry first (#1614). The owner decides per share link, - // because it is a property of how the link is meant to be read, not of the - // journey. Off by default: an already-published link must not reorder itself - // under its readers. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'newest_first')) { - db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0'); - } - }, - /* - * TREK Studio books (#1973). - * - * One row per book, the document itself stored as JSON. A book is a - * document rather than a graph of records: the editor loads it whole, the - * renderer prints it whole, and nothing ever queries "which books contain a - * heart-shaped frame". Normalising spreads and elements into tables would - * buy queries nobody makes and cost a join on every open plus a schema - * migration for every new element kind. - * - * `updated_at` and `version` are what make concurrent editing possible: - * the version increments on every write, and a client that saves against a - * version it did not read gets told rather than silently overwriting. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS journey_books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - title TEXT NOT NULL DEFAULT '', - document TEXT NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id); - `); - }, - /* - * Great Britain gained its counties and boroughs back (#1974). - * - * geoBoundaries' GBR ADM1 is the four constituent countries, so since 3.1.0 - * the atlas had no polygon below "England" and a London borough marked as - * visited had nothing to light up. The bundle now ships GB at ADM2, which is - * the granularity the rest of the world gets and the granularity the - * pre-3.1.0 Natural Earth layer had. - * - * Two things need saying to the data that already exists: - * - * `visited_regions` rows marked before 3.1.0 hold the old Natural Earth - * borough codes. They are reconciled by name within the same country, - * exactly as the 3.1.1 step above does for every other country — a row that - * still cannot be resolved is left alone rather than destroyed, and the - * client's name fallback may well still highlight it. - * - * `place_regions` is a re-derivable cache, and it is full of GB-ENG rows - * that were correct under the old bundle. Nothing re-derives a row that is - * already there, so without clearing them every English place would keep - * reporting "England" forever. - */ - () => { - db.exec(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`); - - type Row = { id: number; region_code: string; region_name: string; country_code: string }; - const rows = db - .prepare(`SELECT id, region_code, region_name, country_code FROM visited_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`) - .all() as Row[]; - if (rows.length === 0) return; - - let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[]; - try { - const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); - features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; - } catch { - return; // bundle unreadable — leave the rows untouched rather than guessing - } - - const validCodes = new Set(); - const nameToCode = new Map(); - for (const f of features) { - const p = f.properties || {}; - if ((p.iso_a2 || '').toUpperCase() !== 'GB' || !p.iso_3166_2) continue; - validCodes.add(p.iso_3166_2); - if (p.name) nameToCode.set(p.name.toLowerCase(), p.iso_3166_2); - } - if (validCodes.size === 0) return; - - const update = db.prepare('UPDATE OR IGNORE visited_regions SET region_code = ? WHERE id = ?'); - for (const row of rows) { - if (validCodes.has(row.region_code)) continue; - const byName = nameToCode.get((row.region_name || '').toLowerCase()); - if (byName) update.run(byName, row.id); - } - }, - // Storage slice 2 — collab note attachments historically stored 'files/' - // in trip_files.filename while the file manager stored bare names in the same - // column; the storage layer addresses objects as category + bare name, so - // normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of - // 'files/'. The LIKE guard makes it a no-op on already-bare rows. - // - // Appended LAST again, and for the same reason as the note this replaces: - // the array is index-addressed against schema_version, so a slot that has - // shipped in dev keeps the index it shipped with and anything from this - // branch goes after it. A database that already ran this migration at its - // pre-merge index replays it harmlessly (the LIKE guard) but skips whatever - // now occupies that index. Acceptable only because this branch has never - // been published; never do this with a released slot. - () => { - db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'"); - }, - // Give back the notes the TICKETJSON step chopped up (#1658). - // - // That step matched with LIKE, which is case-insensitive in SQLite, so a - // note somebody had typed starting "ticketjson:" was read as a receipt: the - // first eleven characters were dropped, the rest was moved into ticket_json - // and the note was set to NULL. The step now matches with GLOB, but that - // only helps a fresh install — the array is index-addressed against - // schema_version, so a repaired step never runs again on a database that - // already applied it. - // - // A receipt comes out of JSON.stringify and always parses; typed text does - // not. So a row moves back only when its ticket_json fails to parse AND the - // note is still empty — anything that parses, and any row someone has - // written a note on since, is left exactly as it is. The eleven marker - // characters themselves are gone for good: their case was the only thing - // separating a receipt from a note, and writing "TICKETJSON:" back would - // hand the row straight to the reader that reads that prefix as a receipt. - // Appended LAST — the array is index-addressed against schema_version. - () => { - const rows = db - .prepare( - `SELECT id, ticket_json FROM budget_items - WHERE ticket_json IS NOT NULL AND ticket_json != '' AND COALESCE(note, '') = ''` - ) - .all() as Array<{ id: number; ticket_json: string }>; - const restore = db.prepare('UPDATE budget_items SET note = ?, ticket_json = NULL WHERE id = ?'); - for (const row of rows) { - try { - JSON.parse(row.ticket_json); - } catch { - restore.run(row.ticket_json, row.id); - } - } - }, - - // A deliberate non-latest plugin install sets `update_hold`: the row leaves the - // update banner and "Update all" until the admin resumes updates, or until an - // install lands back on the newest compatible version. Only an EXPLICIT version - // pick ever sets it — dependency resolution pins versions too, but never - // deliberately. Appended LAST — the array is index-addressed against schema_version. - () => { - try { - db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - - // Reservations an automated ingest parked for review are 'staged' and stay - // out of the two anonymous exports (ICS feed, shared trip) until a person - // confirms them. Nothing writes 'staged' yet; this is the gate the mail - // ingest writes through. - // - // 'live' as the default is what keeps this from being a breaking change: - // SQLite fills every existing row on the ALTER, and no current writer names - // the column, so every booking that is visible today stays visible. - // Appended LAST, the array is index-addressed against schema_version. - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('reservations') WHERE name = 'ingest_state'") - .get(); - if (!hasColumn) { - db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'"); - } - }, - - /* - * Separate an integration key from an MCP token (#2089). - * - * Both live in mcp_tokens and until now both opened everything a token can - * open. That was fine while /mcp was the only consumer; with a public REST - * surface it means a key somebody minted for a chat client also reads their - * trips over HTTP, and a key minted for an integration can drive every MCP - * tool. One credential, two very different blast radii. - * - * `kind` splits them, and every existing row becomes 'mcp' — that is what - * they were issued for, and silently widening a key that is already in - * somebody's config would be the opposite of what this migration is for. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('mcp_tokens')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'kind')) { - db.exec("ALTER TABLE mcp_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'mcp'"); - } - }, - /** - * `naver_list_import` was typed 'trip', but a trip addon is one that earns its own - * tab inside a trip — this one has no tab (it is not in tripTabs.ts) and no page. It - * calls an external service to pull places into the sidebar, which is exactly what - * 'integration' means here. - * - * The type is presentational: only `type === 'global'` is read anywhere - * (client navItems.ts), so this moves the tile between admin groups and changes - * nothing about how the import behaves. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'naver_list_import'").run(); - }, - /** - * Whether a journey's map draws the GPX tracks of the trips behind it (#2194). - * - * #1260 added those tracks unconditionally and with nothing to switch off, - * on the reasoning that a route imported into a linked trip is part of the - * journey's story. For a trip carrying a season of recorded drives it is - * instead a map nobody asked for, drawn from places that never became an - * entry — so it becomes a journey-level setting. - * - * DEFAULT 0, i.e. off: the tracks are opt-in from here on. That is a - * deliberate behaviour change rather than a preserved default — a journal - * should show what its author put in it, and #1260's set is everything the - * linked trips happen to contain. Owners who want them back have one - * switch in Journey Settings. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'show_trip_tracks')) { - db.exec('ALTER TABLE journeys ADD COLUMN show_trip_tracks INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the - * field's effective value when nothing is stored — the settings form pre-fills it and - * the runtime resolves it (settings-defaults.ts); it was previously accepted by the - * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_settings_fields')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'default_value')) { - db.exec('ALTER TABLE plugin_settings_fields ADD COLUMN default_value TEXT'); - } - }, - - // Settings-form actions gain a scope (#plugins): 'user' renders on the user Settings - // tab, 'instance' in the admin instance-settings dialog. Existing rows predate the - // column and were all user-tab buttons, so the default keeps them where they were. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>; - if (cols.length > 0 && !cols.some((c) => c.name === 'scope')) { - db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'"); - } - }, - /** - * A journal entry that is not a stop (discussion #2064). - * - * Studio draws its route and prints its distance from every entry that - * carries coordinates, and that is right until the journal starts at the - * home airport: the night before the flight, the stopover, the place the - * trip was planned from all become stops, and the distance counts the legs - * to and from them. The traveller knows which of those are the journey and - * which are the way there, so the switch sits on the entry. The entry stays - * in the journal; it is only left out of the arithmetic. - * - * DEFAULT 0: every existing entry keeps counting, which is what it did. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'stats_excluded')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - /** - * Place shadow log: which search result a user actually picked. - * - * The corpus behind "would our own index have found that too". No user id, - * no trip, no session and no result list — the evaluation compares a query - * against a pick, and anything beyond that would be collecting for its own - * sake on an instance that promises not to. - * - * The table is created regardless of the switch: the switch decides whether - * rows are written, and a schema that appears only when a feature is - * enabled is a schema that differs between installs. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_shadow_picks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - query TEXT NOT NULL, - lang TEXT, - bias_lat REAL, - bias_lng REAL, - source TEXT NOT NULL, - live_rank INTEGER NOT NULL, - live_count INTEGER NOT NULL, - picked_name TEXT NOT NULL, - picked_lat REAL NOT NULL, - picked_lng REAL NOT NULL, - picked_place_id TEXT - ) - `); - // Retention deletes by age, the export pages by id. - db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)'); - }, - /** - * What kind of stop a place is on a drive — fuel, charging, rest area, campsite. - * - * Deliberately NOT a `categories` row. Those are the traveller's own list, editable - * and instance-wide (`categories.service.ts` selects them without a user filter), so - * seeding four road-trip kinds there would push them into everyone's dropdown and hand - * their colour to whoever edits the list first. A refuelling stop is not a taste; it is - * a fact about the place, and the road-trip categories already own its icon and colour - * (`poiCategories.ts`). - * - * Free text rather than a CHECK constraint: the set grows with what the corridor - * search can look for, and SQLite cannot alter a constraint without rebuilding the - * table. NULL means an ordinary place, which is every row that exists today. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'stop_type')) { - db.exec('ALTER TABLE places ADD COLUMN stop_type TEXT'); - } - }, - /** - * Points a day's drive is made to pass through, without being stops (#1797). - * - * The difference is the whole point: a stop is somewhere you go, and it takes a - * number in the chain, a place row, an arrival time and a line in the itinerary. A - * via is none of that — it only bends the route, which is what "take the coast road - * instead" means. Storing one as a place was the alternative, and it would have put a - * numbered stop in the middle of the day for a spot nobody stops at. - * - * Anchored to `after_order_index` rather than to an assignment id: a stop added - * mid-day is written with a temporary negative id and swapped for the real one moments - * later, so a foreign key to it would dangle. The index is what the routing request is - * built from anyway. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS roadtrip_vias ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - after_order_index INTEGER NOT NULL, - sequence INTEGER NOT NULL DEFAULT 0, - lat REAL NOT NULL, - lng REAL NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)'); - }, - - /** - * Which imported track a day's drive was fitted to. - * - * The vias alone already make the day follow it, so this stores nothing the drive - * needs — it stores what the traveller needs: the name of the road they chose, still - * on the day after a reload, and something to re-fit against when the stops change. - * - * One row per day, hence `day_id` as the key: a day follows one road or none. The - * cascade on `place_id` is the point of the foreign key — delete the imported track - * and the label goes with it, rather than leaving a day claiming to follow a line - * nobody can see any more. The vias it laid down stay, because they are the drive. - * - * `stray_km` is how far the fitted route still ran from the track at its worst point, - * kept so the day can say how good a fit it is without routing again. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS roadtrip_day_tracks ( - day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - stray_km REAL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)'); - }, - /** - * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). - * - * geoBoundaries labelled the whole province with the name of its capital, so - * every row a user collected under it carries the wrong code. All three - * tables that key on a region are moved over: the two per-user ones with an - * UPDATE OR IGNORE plus a DELETE, because a user who already holds the - * correct region would otherwise hit the unique index and keep a duplicate, - * and place_regions with a plain UPDATE, because place_id is its primary key - * and nothing there can collide. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.prepare( - `UPDATE OR IGNORE visited_regions - SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - db.prepare( - `DELETE FROM visited_regions - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - db.prepare( - `UPDATE OR IGNORE place_regions - SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - // hidden_regions is the other direction: it remembers which derived region - // a user switched off. Left behind, the tombstone stops matching and the - // region a user deliberately hid comes back. - db.prepare( - `UPDATE OR IGNORE hidden_regions - SET region_code = 'CN-GUANGDONGPROVINCE' - WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, - ).run(); - db.prepare( - `DELETE FROM hidden_regions - WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, - ).run(); - }, - /** - * Let a file hang off an expense, so a receipt or an invoice can be attached - * to what it paid for. - * - * A column on `file_links` rather than a table of its own: the row already - * ties one file to one thing, and every other attachment kind is a column - * here too. The unique index stops the same receipt being linked twice; - * SQLite treats NULLs as distinct, so the rows that exist today, which all - * carry a NULL here, do not collide with each other. - * - * SET NULL rather than CASCADE, unlike the three columns beside it, because - * those predate the shared row: one row can carry a place link AND a receipt - * link for the same file, and a cascade would delete the whole row when the - * expense goes, silently detaching the file from the place as well. Deleting - * the expense drops the receipt link and nothing else; the budget service - * removes the row afterwards when it carries no other link. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>; - if (!flCols.some((c) => c.name === 'budget_item_id')) { - db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL'); - } - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)'); - }, - /** - * Which chat message an uploaded image belongs to. - * - * A column on `trip_files` rather than a link row, matching the two that - * predate it: a chat image is uploaded for exactly one message and dies with - * it, so the cascade is the whole relationship. Guarded through - * pragma_table_info like every other column add here, not through a caught - * "duplicate column name": that swallows the next error too. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('trip_files')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'message_id')) { - db.exec('ALTER TABLE trip_files ADD COLUMN message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE'); - } - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)'); - }, - /** - * Links somebody shared with the trip. - * - * Its own table rather than a note with a URL in it: a link is pinned, - * ordered and opened, and none of that is what a note does. `user_id` is who - * shared it, so the list can say so and so a member leaving takes their rows - * with them. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(`CREATE TABLE IF NOT EXISTS collab_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - url TEXT NOT NULL, - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)'); - }, - /** - * Route usage counters — how much routing this instance really does. - * - * Daily aggregates, not a log: one row per day, profile, surface and engine - * kind, carrying totals. No query, no coordinate, no route, no user, no trip. - * The question they answer is whether TREK could host a router itself, and - * that needs volume, not itineraries. - * - * The table is created regardless of the switch, like the shadow log above: - * the switch decides whether rows are written, and a schema that appears only - * when a feature is on is a schema that differs between installs. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS route_usage_daily ( - day TEXT NOT NULL, - profile TEXT NOT NULL, - surface TEXT NOT NULL, - self_hosted INTEGER NOT NULL, - requests INTEGER NOT NULL DEFAULT 0, - waypoints INTEGER NOT NULL DEFAULT 0, - km REAL NOT NULL DEFAULT 0, - failed INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (day, profile, surface, self_hosted) - ) - `); - // Retention deletes by day, and the summary reads the newest days first. - db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)'); - }, - /** - * How full THIS stop fills the tank, 1 to 100 (#1797). - * - * Beside stop_type rather than in the traveller's settings, because it is a property - * of the stop and not of the person: a motorway rapid charger gets 80 % because the - * last fifth would cost as long again, while the one at the hotel gets 100 % because - * the car stands there all night. One figure for the whole trip cannot say both, and - * the difference between them is a leg. - * - * NULL means "whatever the traveller's own setting says", which is every row that - * exists today and every stop nobody has an opinion about. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'fill_percent')) { - db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER'); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS school_holiday_countries ( - code TEXT PRIMARY KEY, name TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS school_holiday_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - country TEXT NOT NULL REFERENCES school_holiday_countries(code), - name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1, - UNIQUE(country, name) - ); - CREATE TABLE IF NOT EXISTS school_holiday_periods ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE, - name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, - CHECK (end_date >= start_date) - ); - CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id); - `); - }, - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'end_day')) { - db.exec('ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0'); - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), - from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), - PRIMARY KEY (trip_id, day_number) - )`); - }, - () => { - db.exec('CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))'); - const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL'); - for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key); - }, - - // A settle-up payment's date was silently `created_at` (when it was recorded), - // not editable like a regular expense's `expense_date`. Add the same split: - // settled_at is the calendar day the transfer actually happened, independent - // of when someone got around to logging it. NULL on legacy rows and rows - // whose caller didn't set it; the read side falls back to created_at's date. - // - // Appended LAST: the array is index-addressed against schema_version. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('budget_settlements')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'settled_at')) { - db.exec('ALTER TABLE budget_settlements ADD COLUMN settled_at TEXT'); - } - }, - - /** - * Amap (高德) as a keyed places provider. - * - * users.amap_api_key has the same shape and role as maps_api_key: encrypted - * with apiKeyCrypto, and the last step of the resolver after the operator - * env var and the instance-wide app_settings row. Nullable with no default, - * because "this install does not use Amap" is the correct state for almost - * everybody. Nothing is backfilled: a Google key is not an Amap key, and - * the two are chosen by the places_provider setting, not by which column - * happens to be populated. - * - * places.amap_poi_id is the provider id a place was found by, beside - * google_place_id and osm_id. Amap ids are bare strings shaped like Google - * ones, so the column holds them with the `amap:` prefix the maps domain - * uses everywhere, and a place keeps opening against Amap after the admin - * switches provider. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const userCols = db.prepare("SELECT name FROM pragma_table_info('users')").all() as Array<{ name: string }>; - if (!userCols.some(c => c.name === 'amap_api_key')) { - db.exec('ALTER TABLE users ADD COLUMN amap_api_key TEXT'); - } - const placeCols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!placeCols.some(c => c.name === 'amap_poi_id')) { - db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT'); - } - }, - // Dawarich connection (#2279). Its own table rather than more `users` columns, - // which is where the AirTrail connection lives: that was the right call while - // AirTrail was the only integration of this shape, and this is the second. It - // also carries sync state (cursor, last error, probed capabilities) that has no - // business sitting on the identity row. Credentials for a personal location - // archive stay strictly apart from anything shared on a trip. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS dawarich_connections ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - url TEXT, - api_key TEXT, - allow_insecure_tls INTEGER NOT NULL DEFAULT 0, - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_sync_at TEXT, - last_sync_state TEXT NOT NULL DEFAULT 'never', - last_sync_error TEXT, - capabilities TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - }, - // Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — - // the UNIQUE is what makes a repeated sync idempotent instead of duplicating - // everything it already imported. - // - // `source_hash` exists because a Dawarich visit carries no `updated_at`: a - // rename or a re-detection is only visible as a different hash of the fields - // TREK shows. `source_missing_at` exists because deleting a visit removes it - // from the API rather than tombstoning it, so absence from a full re-read of - // the same window is the only signal — and it is recorded, not acted on, - // because an entry the user already accepted and edited is theirs now. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - source_visit_id TEXT NOT NULL, - trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, - name TEXT NOT NULL, - lat REAL, - lng REAL, - started_at TEXT NOT NULL, - ended_at TEXT NOT NULL, - duration_minutes INTEGER NOT NULL DEFAULT 0, - local_date TEXT NOT NULL, - source_status TEXT NOT NULL DEFAULT 'suggested', - confidence REAL, - confidence_band TEXT, - country_code TEXT, - state TEXT NOT NULL DEFAULT 'new', - target TEXT, - accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - accepted_journal_entry_id INTEGER, - accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, - matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, - source_hash TEXT NOT NULL, - accepted_hash TEXT, - source_missing_at TEXT, - first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, source_visit_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)'); - }, - // Bucket-list entries can now be ticked off (#2279). `visited_source` records - // who decided — a hand-ticked wish and one confirmed from a recording read the - // same on the map otherwise, and re-running a scan must not touch the first. - () => { - const hasVisitedAt = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_at'").get(); - if (!hasVisitedAt) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_at TEXT'); - const hasVisitedSource = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_source'").get(); - if (!hasVisitedSource) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_source TEXT'); - }, - // Where an Atlas country came from (#2279). Everything that exists today was - // marked by hand, so 'manual' is the correct backfill rather than a guess; - // countries confirmed out of a recording are written as 'dawarich' and the - // Atlas can say so. Unmarking still deletes the row either way. - () => { - const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('visited_countries') WHERE name = 'source'").get(); - if (!hasSource) db.exec("ALTER TABLE visited_countries ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'"); - }, - // Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which - // sections a *response* carries; it has never restricted what a key may read, - // and handing an integration a key that reads every trip because it wanted day - // notes is the thing that needed fixing. - // - // `scope_mode` is an explicit flag rather than "NULL means everything": a - // sentinel here would mean a bug that drops the scopes column silently grants - // full access. Every existing key is 'all', so nothing that works today stops - // working — the restriction is opt-in at mint time. - () => { - const hasMode = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'scope_mode'").get(); - if (!hasMode) db.exec("ALTER TABLE mcp_tokens ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'all'"); - const hasScopes = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'api_scopes'").get(); - if (!hasScopes) db.exec('ALTER TABLE mcp_tokens ADD COLUMN api_scopes TEXT'); - }, - // Where a place came from, when it did not come from a person typing it - // (#2279). Only 'dawarich' writes it today; NULL is every place anyone has - // ever added by hand, which is what it should stay. - // - // A column rather than a lookup through dawarich_visit_suggestions: the mark - // has to survive the suggestion being deleted, and a place list would - // otherwise join an integration's table to render a name. - () => { - const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get(); - if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT'); - }, - - // Provenance for a day stop that a lodging booking put there rather than the - // traveller: it carries the stay's id, so moving or deleting the booking can - // move or delete exactly that stop and never one somebody placed by hand. - // Every row that already exists stays NULL: those were planned by hand, and a - // booking must not start claiming ownership of them. The step below adds the - // missing stops instead, which is a different thing from claiming old ones. - () => { - const hasColumn = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'accommodation_id'").get(); - if (!hasColumn) db.exec('ALTER TABLE day_assignments ADD COLUMN accommodation_id INTEGER'); - }, - - /* - * Give every stay booked before this release the day stop it would get today. - * - * Road trip mode builds its drive out of day_assignments alone, so a hotel - * booked in Days mode was invisible there and the traveller had to add the - * same place a second time by hand. New bookings get the stop as they are - * written; without this step the fix would only ever apply to trips planned - * after the upgrade, and the trips people already have would stay broken. - * - * Skipped on purpose: a stay whose place is gone (place_id is ON DELETE SET - * NULL, and the booking form writes stays that never had one), and a place the - * traveller already planned for that day, whose row stays theirs and unmarked. - * Re-runnable: the same NOT EXISTS decides both times. - */ - () => { - const stays = db.prepare(` - SELECT a.id, a.place_id, a.start_day_id - FROM day_accommodations a - WHERE a.place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM day_assignments da - WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id - ) - ORDER BY a.id - `).all() as Array<{ id: number; place_id: number; start_day_id: number }>; - - const insert = db.prepare( - `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) - VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)` - ); - // Arriving somewhere is what the day was for, so the stop goes last, the same - // position a stay booked today lands in. - const stamp = db.prepare("UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')"); - for (const stay of stays) { - insert.run(stay.start_day_id, stay.place_id, stay.start_day_id, stay.id); - stamp.run(stay.place_id); - } - if (stays.length > 0) console.log(`[DB] Put ${stays.length} booked night(s) on their check-in day`); - }, - - /* - * The same sweep once more, for the nights the first one could not have seen. - * - * A migration runs while the old container is still answering: a booking written - * in those seconds is written by code that knows nothing about the day stop, and - * lands behind the sweep that would have given it one. One did, on the test - * instance, out of ten. There is nothing to be done about that window, but there - * is something to be done about what falls into it, and a booking that never got - * its stop is invisible to the drive with no way back short of saving it again. - * - * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its - * stop is passed over, and one whose place the traveller planned by hand keeps - * that row unclaimed. Every future release can carry the same step for the same - * reason. - */ - () => { - const stays = db.prepare(` - SELECT a.id, a.place_id, a.start_day_id - FROM day_accommodations a - WHERE a.place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM day_assignments da - WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id - ) - ORDER BY a.id - `).all() as Array<{ id: number; place_id: number; start_day_id: number }>; - - const insert = db.prepare( - `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) - VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)` - ); - const stamp = db.prepare("UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')"); - for (const stay of stays) { - insert.run(stay.start_day_id, stay.place_id, stay.start_day_id, stay.id); - stamp.run(stay.place_id); - } - if (stays.length > 0) console.log(`[DB] Caught up ${stays.length} booked night(s) missed during the upgrade`); - }, - /** - * A suggestion the traveller has waved away. - * - * Skeletons are real rows, and the trip sync decides what to create by asking - * which source places already have one. Deleting a dismissed suggestion would - * therefore bring it straight back on the next sync. So it stays, marked, and - * drops out of every read instead — which also leaves a way back. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'dismissed')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - * The country an entry happened in, resolved once from its coordinates. - * - * For the flag on the timeline card. Resolved on write rather than on read - * because the answer never changes and the polygon test should not run on - * every render of every entry. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'country_code')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN country_code TEXT'); - } - }, - /** - * Which of the optional entry fields a journey uses. - * - * Mood, weather and the pros/cons list are the three things that make the - * editor feel like a form. Not everybody journals that way, and a journey - * kept by one person for their family should be able to put them away - * without the fields being taken from everybody else (discussion #2299). - * - * DEFAULT 1: every existing journey keeps all three, which is what it had. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>; - for (const col of ['show_verdict', 'show_mood', 'show_weather']) { - if (!cols.some((c) => c.name === col)) { - db.exec(`ALTER TABLE journeys ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`); - } - } - }, - - /* - * Let go of a booking the day stop can no longer reach. - * - * day_assignments.accommodation_id was added as a bare INTEGER, and the only - * code that ever clears it looks the stay up by id. A stay can also vanish - * without anybody asking: day_accommodations.end_day_id is ON DELETE CASCADE, - * so shortening a trip past a booking's last night deletes the booking while - * the stop on its first night survives, now pointing at nothing. Days hides any - * stop that carries an accommodation_id, so the hotel drops out of the day list - * on every surface while the route still drives to it, and the id is - * AUTOINCREMENT, so nothing will ever come along and free the row. - * - * A trigger rather than a column rebuild: day_assignments is referenced by two - * cascading tables of its own, and SQLite fires this even when the stay went - * down with a foreign-key cascade. - */ - () => { - db.exec(` - UPDATE day_assignments SET accommodation_id = NULL - WHERE accommodation_id IS NOT NULL - AND accommodation_id NOT IN (SELECT id FROM day_accommodations) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_day_assignments_accommodation_id ON day_assignments(accommodation_id)'); - db.exec(` - CREATE TRIGGER IF NOT EXISTS trg_release_stop_on_stay_delete - AFTER DELETE ON day_accommodations - BEGIN - UPDATE day_assignments SET accommodation_id = NULL WHERE accommodation_id = OLD.id; - END - `); - }, - - /* - * A journal skeleton belongs to a day, not just to a place (#2329). - * - * The sync engine keyed a skeleton by `source_place_id` alone, so the same - * place standing on two days — the city you land in at dusk and walk through - * the next morning, the hotel you sleep in three nights — produced one entry - * on the first of them and nothing on the rest. There was nowhere to put the - * second evening's photographs but the first evening's entry. - * - * The assignment row, not the day, is the missing half of that key: moving a - * stop to another day updates `day_assignments.day_id` in place, so keying on - * the assignment lets an entry follow the move the way it always has, while - * still telling two days of the same place apart. - * - * Deliberately no REFERENCES clause. Unassigning a stop has to leave the id - * stale rather than NULL, because reconciliation reads a key that no longer - * matches as "this stop left the plan" — which is what happened — and a NULL - * would be indistinguishable from a row this migration could not resolve. The - * id is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never - * handed out twice. - * - * Existing rows are backfilled to the place's earliest assignment, which is - * the one the old engine would have picked, so reconciliation matches what is - * already there instead of writing a duplicate beside it. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'source_assignment_id'") - .get(); - if (!hasColumn) db.exec('ALTER TABLE journey_entries ADD COLUMN source_assignment_id INTEGER'); - // `source_assignment_id IS NULL` keeps the replay from re-resolving a row that - // has since followed its stop to another day. - db.exec(` - UPDATE journey_entries - SET source_assignment_id = ( - SELECT da.id - FROM day_assignments da - JOIN days d ON d.id = da.day_id - WHERE da.place_id = journey_entries.source_place_id - ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC - LIMIT 1 - ) - WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)', - ); - }, - - /* - * Document providers, part 1 of 3: the registry (#214). - * - * Deliberately its own pair of tables rather than a `kind` column on - * `photo_providers`. The client filters on `type === 'photo_provider'`, the - * Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no - * WHERE clause, and migrations are append-only. Reusing those tables would - * change the behaviour of three existing paths, which is exactly what the - * no-breaking-changes rule forbids. The field columns follow - * `photo_provider_fields` except for `settings_key` and `payload_key`. - * Those map a photo field onto a settings key and a request key; a document - * field is stored under its own `field_key` in one JSON column, and the - * connect form takes its fields from /docsync/providers rather than from - * the generic settings form, so nothing would ever read them. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_providers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - icon TEXT DEFAULT 'FileText', - enabled INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS document_provider_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, - field_key TEXT NOT NULL, - label TEXT NOT NULL, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER DEFAULT 0, - secret INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - UNIQUE(provider_id, field_key) - ); - `); - seedDocumentProviders(db); - }, - - /* - * Document providers, part 2 of 3: the connection, and the trip binding. - * - * The connection carries a `trip_id`, and that is the one place this design - * departs from every integration already in the repo. Immich, Synology - * Photos, AirTrail and Dawarich all hang off a user, and even - * `trip_album_links` carries a `user_id`; photos become visible to the rest - * of a trip only through an opt-in `shared` flag. None of that can satisfy - * "everyone on the trip sees the same documents": it would make a - * document's visibility depend on whose credentials fetched it. So the trip - * admin binds the trip once, the server talks to the provider under that - * single identity, and TREK's own membership decides who sees what. - * - * `owner_user_id` stays separate from `trip_id` so the credential holder is - * always explicit: when that person leaves the trip the binding goes to - * `orphaned` rather than silently continuing to use an ex-member's token. - * - * Secrets live in one encrypted JSON blob instead of per-provider columns: - * a sixth provider then needs no migration, and the key rotation in - * scripts/migrate-encryption.ts stays one line instead of a field list that - * someone will forget, and a forgotten column does not survive a rotation. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_connections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, - owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - base_url TEXT NOT NULL, - secrets TEXT, - settings TEXT NOT NULL DEFAULT '{}', - allow_insecure_tls INTEGER NOT NULL DEFAULT 0, - capabilities TEXT, - last_probe_at TEXT, - last_probe_state TEXT NOT NULL DEFAULT 'never', - last_probe_error TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, provider_id) - ); - CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id); - CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id); - - CREATE TABLE IF NOT EXISTS trip_document_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE, - provider_id TEXT NOT NULL, - remote_scope_key TEXT NOT NULL, - remote_root_id TEXT, - remote_root_path TEXT, - remote_label TEXT NOT NULL DEFAULT '', - direction TEXT NOT NULL DEFAULT 'both', - delete_policy TEXT NOT NULL DEFAULT 'unlink', - conflict_policy TEXT NOT NULL DEFAULT 'manual', - sync_enabled INTEGER NOT NULL DEFAULT 1, - webhook_token TEXT, - webhook_secret TEXT, - webhook_subscription_id TEXT, - remote_cursor TEXT, - last_sync_at TEXT, - last_sync_state TEXT NOT NULL DEFAULT 'never', - last_sync_error TEXT, - failure_count INTEGER NOT NULL DEFAULT 0, - next_attempt_at TEXT, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, connection_id, remote_scope_key) - ); - CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at); - CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token - ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL; - `); - }, - - /* - * Document providers, part 3 of 3: the pairing and its sync state. - * - * `content_sha256` and `pushed_sha256` are two columns on purpose. The - * first is the bytes both sides last agreed on, the second is what TREK - * itself last uploaded. Collapsing them into one is precisely the mistake - * that builds an echo loop: a webhook fires for TREK's own write, the core - * cannot tell it from a stranger's edit, and the file bounces. - * - * `remote_missing_at` records that something vanished upstream instead of - * acting on it, the rule Dawarich already follows with `source_missing_at`. - * An unmounted share answers with an empty listing, and reading that as - * "everything was deleted" would empty a trip. - * - * `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is - * permanently deleted in TREK, so the next run does not cheerfully download - * it again. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_sync_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL, - trek_doc_uid TEXT NOT NULL, - remote_id TEXT, - remote_name TEXT, - remote_version TEXT, - remote_size INTEGER, - remote_modified_at TEXT, - content_sha256 TEXT, - pushed_sha256 TEXT, - state TEXT NOT NULL DEFAULT 'pending', - error_code TEXT, - attempts INTEGER NOT NULL DEFAULT 0, - next_attempt_at TEXT, - remote_missing_at TEXT, - first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - synced_at TEXT - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote - ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file - ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid - ON document_sync_items(link_id, trek_doc_uid); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at); - `); - }, - - /* - * Document providers: when TREK itself put a provider copy in the bin. - * - * A file deleted in TREK under the `trash` policy takes its provider copy - * with it. Taken back out of TREK's trash, it has to go up again; a copy - * somebody else deleted in the meantime must not. Both leave the same gap - * in a listing, so the difference is written down when TREK acts rather - * than guessed at later. - * - * No backfill from the binding's current policy: that policy may not be - * the one the deletion ran under, and reading it back is the retroactive - * mistake this column exists to avoid. A row left NULL is treated like a - * copy somebody else deleted, which flags it instead of uploading it. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('document_sync_items') WHERE name = 'remote_trashed_at'") - .get(); - if (!hasColumn) db.exec('ALTER TABLE document_sync_items ADD COLUMN remote_trashed_at TEXT'); - }, - - /* - * Trips longer than a year lost every day past the 365th: generateDays - * clipped the day rows at the old limit while the trip kept its full end - * date, so the last days had a date but nothing to plan on (#2403). The - * limit is 999 now, and this gives the affected trips their missing days. - * - * Only a range whose dated days still run unbroken from the start date is - * extended; a trip that was re-dated by hand or lost a day in the middle is - * left as it is. Dateless days that still hold content stay behind the - * dated ones, where generateDays keeps them. The two-phase renumbering is - * the same dance generateDays does around UNIQUE(trip_id, day_number). - */ - () => { - const dayAfter = (start: string, n: number) => { - const [y, m, d] = start.split('-').map(Number); - return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10); - }; - const trips = db - .prepare(` - SELECT id, start_date, end_date, - CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span - FROM trips - WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' - AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' - AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999 - `) - .all() as { id: number; start_date: string; end_date: string; span: number }[]; - const dayRows = db.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number'); - const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - for (const trip of trips) { - const rows = dayRows.all(trip.id) as { id: number; date: string | null }[]; - const dated = rows.filter((r) => r.date); - if (dated.length >= trip.span) continue; - if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue; - const dateless = rows.filter((r) => !r.date); - rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id)); - dated.forEach((r, i) => setDayNumber.run(i + 1, r.id)); - for (let i = dated.length; i < trip.span; i++) insertDay.run(trip.id, i + 1, dayAfter(trip.start_date, i)); - dateless.forEach((r, i) => setDayNumber.run(trip.span + i + 1, r.id)); - } - }, - - /* - * The road-trip day boundaries carried the old trip limit as a CHECK on - * day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the - * table keeps only the floor. SQLite cannot alter a CHECK, hence the - * rebuild; nothing references the table, so the rows are simply copied. - */ - () => { - db.exec(` - CREATE TABLE roadtrip_day_boundaries_new ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL CHECK (day_number >= 1), - from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), - PRIMARY KEY (trip_id, day_number) - ); - INSERT INTO roadtrip_day_boundaries_new - SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries; - DROP TABLE roadtrip_day_boundaries; - ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries; - `); - }, - - /* - * Seat every booked night where its check-in says, the way a night booked - * today is seated. The night leads its day now; the stops a booking put on - * their check-in day before this release sit last, and the trips people - * already have would keep that order until somebody edits the check-in. - * The rules, and the drawn roads that follow the stops, are in - * reseat-booked-nights.ts. Re-runnable. - */ - () => { - const seated = reseatBookedNights(db); - if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`); - }, - - /* - * Immich learns the switch Synology, AirTrail and Dawarich already have: a - * server behind a self-signed certificate can be trusted per user (#2475). - * Off by default, and only 1 counts as on. - * - * The two settings rows are the toggle for it and the auto-upload toggle - * migration 112 meant to add. That one only ran where the Immich provider - * row already existed, which on a fresh install it never did (the seeds run - * after the migrations), so fresh installs never showed the upload toggle. - * Both rows are in seeds.ts as well for the same reason; here they reach the - * installs that already have the provider row. Re-runnable. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'") - .get(); - if (!hasColumn) { - db.exec('ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0'); - } - const hasImmich = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich'").get(); - if (hasImmich) { - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) - VALUES - ('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2), - ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5) - `); - } - }, - - /* - * A place that moves takes its Atlas country with it (#2527). - * - * place_regions caches the country and region Atlas resolved from a - * place's coordinates and address, and nothing re-derives a row that is - * already there. Correcting a place's location left the old row in charge, - * so Atlas, the dashboard stats and the journey stats kept counting the - * country the place had just left. - * - * A trigger rather than a delete in the places code, because the row is - * derived from the place and every writer of lat, lng or address has to - * let go of it: the place editor, update_place, the plugin RPC, the import - * enrichment and its address backfill, and whatever comes next. The WHEN - * clause matters because every place edit writes lat, lng and address - * back whether they changed or not, and renaming a place must not throw - * away a good row. The next Atlas load resolves the place where it is now. - * - * This is 244 on main (4.3.3) as well, so it sits here, ahead of Web Push. - */ - () => createPlaceRegionsFollowPlaceTrigger(db), - - /* - * Web Push (#894): one row per browser a user switched push on in. - * - * The endpoint is the push service URL that browser handed out, so it is - * unique: a browser shared by two accounts belongs to whoever subscribed on - * it last. p256dh and auth are the browser's keys for the RFC 8291 message - * encryption. vapid_public_key is the server key the subscription was made - * against, so a changed key pair shows up as a mismatch the sender can clean - * up, rather than as a push service refusing every message. failure_count - * and last_success_at are bookkeeping for the sender; the rows go with the - * user. - * - * Web Push was 244 before the #2527 trigger took that slot to match main. - * A 4.3.3 install is already at 244 with the trigger and gets the table - * here; an instance that ran this at 244 replays it as a no-op. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - endpoint TEXT NOT NULL UNIQUE, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - vapid_public_key TEXT NOT NULL, - user_agent TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_success_at TEXT, - failure_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); - `); - }, - - /* - * The #2527 trigger once more, for an instance that ran Web Push at 244 - * before the trigger took that slot. Such an instance never runs 244 again, - * so it gets the trigger here. Everywhere else it already exists and this - * is a no-op. - */ - () => createPlaceRegionsFollowPlaceTrigger(db), - - /* - * A stop kept on the day but left out of its route (#2532): shown as a pin, not - * driven to. 0 routes it like every stop before this existed. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'route_excluded'").get(); - if (!has) db.exec('ALTER TABLE day_assignments ADD COLUMN route_excluded INTEGER NOT NULL DEFAULT 0'); - }, - - /* - * How many of an item are packed so far (#2296), for "7 of 10 shirts". NULL is the - * plain checkbox every item has had, and `checked` stays the answer to "done?". - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('packing_items') WHERE name = 'packed_quantity'").get(); - if (!has) db.exec('ALTER TABLE packing_items ADD COLUMN packed_quantity INTEGER'); - }, - - /* - * A bucket-list wish for one state or province rather than the whole country - * (#1901). NULL keeps every existing wish a country wish. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'region_code'").get(); - if (!has) db.exec('ALTER TABLE bucket_list ADD COLUMN region_code TEXT'); - }, - - /* - * Half company holidays (#2439): 0.5 covers the morning or afternoon and leaves - * the other half open for a half vacation day. 1 is what every holiday was. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('vacay_company_holidays') WHERE name = 'fraction'").get(); - if (!has) db.exec('ALTER TABLE vacay_company_holidays ADD COLUMN fraction REAL NOT NULL DEFAULT 1'); - }, - - /* - * A journey entry still being written (#696): contributors see it, the public - * share page does not. Its own flag, since `visibility` defaults to 'private' on - * every entry and filtering on it would empty every shared journey. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'is_draft'").get(); - if (!has) db.exec('ALTER TABLE journey_entries ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0'); - }, - - /* - * Two narrower ways to share a trip (#1712): only the travel (flights, trains, - * stays) without the day's activities, and without the place photos. Both off - * keeps every existing link exactly as it was. - */ - () => { - const hasTravel = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_travel_only'").get(); - if (!hasTravel) db.exec('ALTER TABLE share_tokens ADD COLUMN share_travel_only INTEGER NOT NULL DEFAULT 0'); - const hasImages = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_hide_images'").get(); - if (!hasImages) db.exec('ALTER TABLE share_tokens ADD COLUMN share_hide_images INTEGER NOT NULL DEFAULT 0'); - }, - /** - * A note on a settle-up payment (#2340), as expenses already have one: - * "paid back in cash at the airport". Null on every existing payment. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('budget_settlements') WHERE name = 'note'").get(); - if (!has) db.exec('ALTER TABLE budget_settlements ADD COLUMN note TEXT'); - }, - /** - * Google API calls per UTC day (#1582), so the admin's daily ceiling has - * something to count against. One row a day, nothing about who searched. - */ - () => { - db.exec('CREATE TABLE IF NOT EXISTS google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0)'); - }, - /** - * Packing templates remember what an item weighs, how many of it go along - * and which bag it lives in (#1131), so applying one does not mean typing - * all of that in again. The bag is kept by name: bags belong to a trip. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('packing_template_items')").all() as { name: string }[]; - const has = (c: string) => cols.some(col => col.name === c); - if (!has('weight_grams')) db.exec('ALTER TABLE packing_template_items ADD COLUMN weight_grams INTEGER'); - if (!has('quantity')) db.exec('ALTER TABLE packing_template_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); - if (!has('bag_name')) db.exec('ALTER TABLE packing_template_items ADD COLUMN bag_name TEXT'); - }, - /** - * A journey's state as its owner sets it (#762): draft, live or completed. - * NULL keeps the state derived from the linked trips' dates, which is what - * every journey had so far; a journey with no trip could only ever be a draft. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; - if (!cols.some(c => c.name === 'status_override')) db.exec('ALTER TABLE journeys ADD COLUMN status_override TEXT'); - }, - /** - * A place's e-mail and its own opening hours, typed in by hand (#2472): the - * search fills in what it knows, and these are for everything it does not. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as { name: string }[]; - const has = (c: string) => cols.some(col => col.name === c); - if (!has('email')) db.exec('ALTER TABLE places ADD COLUMN email TEXT'); - if (!has('opening_hours')) db.exec('ALTER TABLE places ADD COLUMN opening_hours TEXT'); - }, - /** - * A journey that puts an entry on the map where its first photo was taken - * (#1003). Off unless the owner turns it on: an entry somebody placed by hand - * is never moved, but an entry left without a place on purpose should not - * grow one either just because a picture was added. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; - if (!cols.some(c => c.name === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0'); - }, - ]; - - if (currentVersion < migrations.length) { - for (let i = currentVersion; i < migrations.length; i++) { - console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); - try { - const migration = migrations[i]; - if (typeof migration === 'function') { - // The version bump has to commit together with the migration. Bumped - // afterwards, a crash in between leaves the schema advanced and the - // version stale, and the next boot replays a step that is not - // idempotent, exits 1, and turns one crash into a permanent boot loop. - db.transaction(() => { - migration(); - db.prepare('UPDATE schema_version SET version = ?').run(i + 1); - })(); - } else { - // raw steps run outside a transaction on purpose (see PRAGMA - // foreign_keys above), so the bump stays a separate statement. - migration.raw(); - db.prepare('UPDATE schema_version SET version = ?').run(i + 1); - } - } catch (err) { - console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); - process.exit(1); - } - } - console.log(`[DB] Migrations complete — schema version ${migrations.length}`); - } -} - -export { runMigrations }; diff --git a/server/src/db/migrations/Migration20200101000000_baseline_schema.ts b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts new file mode 100644 index 000000000..8aa7da5cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts @@ -0,0 +1,774 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Baseline schema — every table `db/schema.ts::createTables()` created on boot, + * before the numbered steps in `db/migrations.ts` ran. + * + * This is not a frozen "v0": `createTables()` was edited as features landed, so it + * is a snapshot of the schema as it stood when the ORM migration took place. The + * `IF NOT EXISTS` guards are kept verbatim, and the numbered steps that follow + * replay on top of it — each one guarded so a column this file already declares + * is not added twice. + * + * The trailing `schema_version` table is the legacy runner's own bookkeeping, + * which it created before running step 1. It is left empty here: the migration + * state now lives in the migrator's table, and nothing in the app reads this + * one. It is kept because step 129 rebuilds it and it is still an entity. + */ +export class Migration20200101000000_baseline_schema extends Migration { + override name = 'Migration20200101000000_baseline_schema'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + maps_api_key TEXT, + unsplash_api_key TEXT, + amap_api_key TEXT, + openweather_api_key TEXT, + avatar TEXT, + oidc_sub TEXT, + oidc_issuer TEXT, + last_login DATETIME, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret TEXT, + mfa_backup_codes TEXT, + immich_url TEXT, + immich_access_token TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, + must_change_password INTEGER DEFAULT 0, + password_version INTEGER NOT NULL DEFAULT 0, + feed_token TEXT, + is_guest INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + UNIQUE(user_id, key) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + currency TEXT DEFAULT 'EUR', + cover_image TEXT, + is_archived INTEGER DEFAULT 0, + reminder_days INTEGER DEFAULT 3, + feed_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL, + date TEXT, + notes TEXT, + title TEXT, + UNIQUE(trip_id, day_number) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT '📍', + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#10b981', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + place_time TEXT, + end_time TEXT, + duration_minutes INTEGER DEFAULT 60, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + website TEXT, + phone TEXT, + transport_mode TEXT DEFAULT 'walking', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_tags ( + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (place_id, tag_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + order_index INTEGER DEFAULT 0, + notes TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + caption TEXT, + taken_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, + title TEXT NOT NULL, + accommodation_id TEXT, + reservation_time TEXT, + reservation_end_time TEXT, + location TEXT, + confirmation_number TEXT, + notes TEXT, + status TEXT DEFAULT 'pending', + type TEXT DEFAULT 'other', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_by INTEGER REFERENCES users(id), + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + text TEXT NOT NULL, + time TEXT, + icon TEXT DEFAULT '📝', + sort_order REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Addon system + CREATE TABLE IF NOT EXISTS addons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'global', + icon TEXT DEFAULT 'Puzzle', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + this.addSql(` + -- Vacay addon tables + CREATE TABLE IF NOT EXISTS vacay_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + block_weekends INTEGER DEFAULT 1, + holidays_enabled INTEGER DEFAULT 0, + holidays_region TEXT DEFAULT '', + school_holidays_enabled INTEGER DEFAULT 0, + company_holidays_enabled INTEGER DEFAULT 1, + carry_over_enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_plan_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(plan_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_colors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + color TEXT DEFAULT '#6366f1', + UNIQUE(user_id, plan_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + UNIQUE(plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + vacation_days INTEGER DEFAULT 30, + carried_over INTEGER DEFAULT 0, + UNIQUE(user_id, plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(user_id, plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_company_holidays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'public_holiday', + region TEXT NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT '#fecaca', + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + this.addSql(` + -- Collections addon tables + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + role TEXT NOT NULL DEFAULT 'editor', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + + this.addSql(` + -- Per-collection custom labels (distinct from the instance-wide tags table): + -- each list defines its own labels and every place can carry several. + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_accommodations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Collab addon tables + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + this.addSql( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + + this.addSql(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); + } +} diff --git a/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts new file mode 100644 index 000000000..6e9023343 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 1 (`db/migrations.ts`). + */ +export class Migration20200101000100_users_add_unsplash_api_key extends Migration { + override name = 'Migration20200101000100_users_add_unsplash_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'unsplash_api_key', `unsplash_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts new file mode 100644 index 000000000..8999dd1c9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 2 (`db/migrations.ts`). + */ +export class Migration20200101000200_users_add_openweather_api_key extends Migration { + override name = 'Migration20200101000200_users_add_openweather_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'openweather_api_key', `openweather_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts new file mode 100644 index 000000000..fcd3cc89f --- /dev/null +++ b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 3 (`db/migrations.ts`). + */ +export class Migration20200101000300_places_add_duration_minutes extends Migration { + override name = 'Migration20200101000300_places_add_duration_minutes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'duration_minutes', `duration_minutes INTEGER DEFAULT 60`); + } +} diff --git a/server/src/db/migrations/Migration20200101000400_places_add_notes.ts b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts new file mode 100644 index 000000000..ef2fa8c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 4 (`db/migrations.ts`). + */ +export class Migration20200101000400_places_add_notes extends Migration { + override name = 'Migration20200101000400_places_add_notes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'notes', `notes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts new file mode 100644 index 000000000..143d55872 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 5 (`db/migrations.ts`). + */ +export class Migration20200101000500_places_add_image_url extends Migration { + override name = 'Migration20200101000500_places_add_image_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'image_url', `image_url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts new file mode 100644 index 000000000..db8e9a7f0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 6 (`db/migrations.ts`). + */ +export class Migration20200101000600_places_add_transport_mode extends Migration { + override name = 'Migration20200101000600_places_add_transport_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'transport_mode', `transport_mode TEXT DEFAULT 'walking'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000700_days_add_title.ts b/server/src/db/migrations/Migration20200101000700_days_add_title.ts new file mode 100644 index 000000000..ce2b6c6e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000700_days_add_title.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 7 (`db/migrations.ts`). + */ +export class Migration20200101000700_days_add_title extends Migration { + override name = 'Migration20200101000700_days_add_title'; + + override async up(): Promise { + await addColumnIfMissing(this, 'days', 'title', `title TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts new file mode 100644 index 000000000..cd81010cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 8 (`db/migrations.ts`). + */ +export class Migration20200101000800_reservations_add_status extends Migration { + override name = 'Migration20200101000800_reservations_add_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'status', `status TEXT DEFAULT 'pending'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts new file mode 100644 index 000000000..7d1266588 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 9 (`db/migrations.ts`). + */ +export class Migration20200101000900_trip_files_add_reservation_id extends Migration { + override name = 'Migration20200101000900_trip_files_add_reservation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts new file mode 100644 index 000000000..93e18b291 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 10 (`db/migrations.ts`). + */ +export class Migration20200101001000_reservations_add_type extends Migration { + override name = 'Migration20200101001000_reservations_add_type'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'type', `type TEXT DEFAULT 'other'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts new file mode 100644 index 000000000..2f9ccb444 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 11 (`db/migrations.ts`). + */ +export class Migration20200101001100_trips_add_cover_image extends Migration { + override name = 'Migration20200101001100_trips_add_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts new file mode 100644 index 000000000..e93a3ba86 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 12 (`db/migrations.ts`). + */ +export class Migration20200101001200_day_notes_add_icon extends Migration { + override name = 'Migration20200101001200_day_notes_add_icon'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'icon', `icon TEXT DEFAULT '📝'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts new file mode 100644 index 000000000..045008552 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 13 (`db/migrations.ts`). + */ +export class Migration20200101001300_trips_add_is_archived extends Migration { + override name = 'Migration20200101001300_trips_add_is_archived'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'is_archived', `is_archived INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts new file mode 100644 index 000000000..7d043a8ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 14 (`db/migrations.ts`). + */ +export class Migration20200101001400_categories_add_user_id extends Migration { + override name = 'Migration20200101001400_categories_add_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'categories', 'user_id', `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts new file mode 100644 index 000000000..34e82e319 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 15 (`db/migrations.ts`). + */ +export class Migration20200101001500_users_add_avatar extends Migration { + override name = 'Migration20200101001500_users_add_avatar'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'avatar', `avatar TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts new file mode 100644 index 000000000..254784fab --- /dev/null +++ b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 16 (`db/migrations.ts`). + */ +export class Migration20200101001600_users_add_oidc_sub extends Migration { + override name = 'Migration20200101001600_users_add_oidc_sub'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_sub', `oidc_sub TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts new file mode 100644 index 000000000..025bf2716 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 17 (`db/migrations.ts`). + */ +export class Migration20200101001700_users_add_oidc_issuer extends Migration { + override name = 'Migration20200101001700_users_add_oidc_issuer'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_issuer', `oidc_issuer TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts new file mode 100644 index 000000000..0a68c40e5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 18 (`db/migrations.ts`). + */ +export class Migration20200101001800_users_add_last_login extends Migration { + override name = 'Migration20200101001800_users_add_last_login'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'last_login', `last_login DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts new file mode 100644 index 000000000..c2078511a --- /dev/null +++ b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts @@ -0,0 +1,43 @@ +import { tableSql } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 19 (`db/migrations.ts`). + * + * Drops the `NOT NULL DEFAULT 1` on `budget_items.persons`/`days` so an expense + * can leave them unset. SQLite cannot alter a column default, hence the rebuild; + * it only fires on databases whose stored DDL still carries the old constraint. + * + * The legacy step matched the bare string `NOT NULL DEFAULT 1` anywhere in the + * table's DDL. That is narrowed here to the two columns it is actually about: + * step 132 later adds `exchange_rate REAL NOT NULL DEFAULT 1`, so the loose test + * reports true on a fully-migrated table and the 10-column rebuild below then + * fails against its 17 columns. The legacy runner never hit that because + * `schema_version` stopped the step re-running; nothing gates it here. + */ +export class Migration20200101001900_create_budget_items_new extends Migration { + override name = 'Migration20200101001900_create_budget_items_new'; + + override async up(): Promise { + const sql = await tableSql(this, 'budget_items'); + if (!sql || !/\bpersons\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1/i.test(sql)) return; + + await this.execute(` + CREATE TABLE budget_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`INSERT INTO budget_items_new SELECT * FROM budget_items`); + await this.execute(`DROP TABLE budget_items`); + await this.execute(`ALTER TABLE budget_items_new RENAME TO budget_items`); + } +} diff --git a/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts new file mode 100644 index 000000000..b9497dfcc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 20 (`db/migrations.ts`). + */ +export class Migration20200101002000_day_accommodations_add_check_in extends Migration { + override name = 'Migration20200101002000_day_accommodations_add_check_in'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in', `check_in TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'check_out', `check_out TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'confirmation', `confirmation TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts new file mode 100644 index 000000000..07d83e7b8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 21 (`db/migrations.ts`). + */ +export class Migration20200101002100_places_add_end_time extends Migration { + override name = 'Migration20200101002100_places_add_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'end_time', `end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts new file mode 100644 index 000000000..09ef7c4ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts @@ -0,0 +1,31 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 22 (`db/migrations.ts`). + * + * Moves reservation state off the place and onto the day assignment, so the same + * place booked on two days keeps two independent bookings. The copy is + * best-effort: it reads `places` columns that a later step removes, and the + * original step logged and swallowed a failure rather than blocking the boot. + */ +export class Migration20200101002200_day_assignments_add_reservation_status extends Migration { + override name = 'Migration20200101002200_day_assignments_add_reservation_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'reservation_status', `reservation_status TEXT DEFAULT 'none'`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_notes', `reservation_notes TEXT`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_datetime', `reservation_datetime TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), + reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), + reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) + WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts new file mode 100644 index 000000000..92bdda5dc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 23 (`db/migrations.ts`). + */ +export class Migration20200101002300_reservations_add_assignment_id extends Migration { + override name = 'Migration20200101002300_reservations_add_assignment_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'assignment_id', + `assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts new file mode 100644 index 000000000..a9e8b0cad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 24 (`db/migrations.ts`). + */ +export class Migration20200101002400_create_assignment_participants extends Migration { + override name = 'Migration20200101002400_create_assignment_participants'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts new file mode 100644 index 000000000..8c9285458 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts @@ -0,0 +1,69 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 25 (`db/migrations.ts`). + * + * The Collab addon: shared notes, polls and trip chat. + */ +export class Migration20200101002500_create_collab_notes extends Migration { + override name = 'Migration20200101002500_create_collab_notes'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts new file mode 100644 index 000000000..5696d0c4f --- /dev/null +++ b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 26 (`db/migrations.ts`). + */ +export class Migration20200101002600_day_assignments_add_assignment_time extends Migration { + override name = 'Migration20200101002600_day_assignments_add_assignment_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'assignment_time', `assignment_time TEXT`); + + await addColumnIfMissing(this, 'day_assignments', 'assignment_end_time', `assignment_end_time TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), + assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts new file mode 100644 index 000000000..1409c5efa --- /dev/null +++ b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 27 (`db/migrations.ts`). + */ +export class Migration20200101002700_create_budget_item_members extends Migration { + override name = 'Migration20200101002700_create_budget_item_members'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_item_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + paid INTEGER NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts new file mode 100644 index 000000000..c47466faf --- /dev/null +++ b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 28 (`db/migrations.ts`). + */ +export class Migration20200101002800_create_collab_message_reactions extends Migration { + override name = 'Migration20200101002800_create_collab_message_reactions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id, emoji) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts new file mode 100644 index 000000000..b499c3c2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 29 (`db/migrations.ts`). + */ +export class Migration20200101002900_collab_messages_add_deleted extends Migration { + override name = 'Migration20200101002900_collab_messages_add_deleted'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collab_messages', 'deleted', `deleted INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts new file mode 100644 index 000000000..729cff68d --- /dev/null +++ b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 30 (`db/migrations.ts`). + */ +export class Migration20200101003000_trip_files_add_note_id extends Migration { + override name = 'Migration20200101003000_trip_files_add_note_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'note_id', + `note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'collab_notes', 'website', `website TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts new file mode 100644 index 000000000..1791d5527 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 31 (`db/migrations.ts`). + */ +export class Migration20200101003100_reservations_add_reservation_end_time extends Migration { + override name = 'Migration20200101003100_reservations_add_reservation_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'reservation_end_time', `reservation_end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts new file mode 100644 index 000000000..264c45027 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 32 (`db/migrations.ts`). + */ +export class Migration20200101003200_places_add_osm_id extends Migration { + override name = 'Migration20200101003200_places_add_osm_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'osm_id', `osm_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts new file mode 100644 index 000000000..b8426d324 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 33 (`db/migrations.ts`). + */ +export class Migration20200101003300_trip_files_add_uploaded_by extends Migration { + override name = 'Migration20200101003300_trip_files_add_uploaded_by'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'uploaded_by', + `uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'trip_files', 'starred', `starred INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'trip_files', 'deleted_at', `deleted_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts new file mode 100644 index 000000000..0d379147e --- /dev/null +++ b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 34 (`db/migrations.ts`). + */ +export class Migration20200101003400_reservations_add_accommodation_id extends Migration { + override name = 'Migration20200101003400_reservations_add_accommodation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'accommodation_id', + `accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'reservations', 'metadata', `metadata TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts new file mode 100644 index 000000000..cbbfe2904 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts @@ -0,0 +1,22 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 35 (`db/migrations.ts`). + */ +export class Migration20200101003500_create_invite_tokens extends Migration { + override name = 'Migration20200101003500_create_invite_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts new file mode 100644 index 000000000..811ee8166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 36 (`db/migrations.ts`). + */ +export class Migration20200101003600_users_add_mfa_enabled extends Migration { + override name = 'Migration20200101003600_users_add_mfa_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_enabled', `mfa_enabled INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'users', 'mfa_secret', `mfa_secret TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts new file mode 100644 index 000000000..85b89a89c --- /dev/null +++ b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 37 (`db/migrations.ts`). + */ +export class Migration20200101003700_create_packing_category_assignees extends Migration { + override name = 'Migration20200101003700_create_packing_category_assignees'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts new file mode 100644 index 000000000..57a438029 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts @@ -0,0 +1,40 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 38 (`db/migrations.ts`). + */ +export class Migration20200101003800_create_packing_templates extends Migration { + override name = 'Migration20200101003800_create_packing_templates'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_template_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + await execBestEffort(this, `DROP TABLE IF EXISTS packing_template_items`); + + await this.execute(` + CREATE TABLE packing_template_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts new file mode 100644 index 000000000..695d773ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts @@ -0,0 +1,32 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 39 (`db/migrations.ts`). + */ +export class Migration20200101003900_create_packing_bags extends Migration { + override name = 'Migration20200101003900_create_packing_bags'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6366f1', + weight_limit_grams INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await addColumnIfMissing(this, 'packing_items', 'weight_grams', `weight_grams INTEGER`); + + await addColumnIfMissing( + this, + 'packing_items', + 'bag_id', + `bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts new file mode 100644 index 000000000..c46857845 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 40 (`db/migrations.ts`). + */ +export class Migration20200101004000_create_visited_countries extends Migration { + override name = 'Migration20200101004000_create_visited_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, country_code) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts new file mode 100644 index 000000000..f372c8f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 41 (`db/migrations.ts`). + */ +export class Migration20200101004100_create_bucket_list extends Migration { + override name = 'Migration20200101004100_create_bucket_list'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS bucket_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + lat REAL, + lng REAL, + country_code TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts new file mode 100644 index 000000000..1e0d455cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 42 (`db/migrations.ts`). + */ +export class Migration20200101004200_vacay_plans_add_weekend_days extends Migration { + override name = 'Migration20200101004200_vacay_plans_add_weekend_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'weekend_days', `weekend_days TEXT DEFAULT '0,6'`); + } +} diff --git a/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts new file mode 100644 index 000000000..0ce188e30 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts @@ -0,0 +1,35 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 43 (`db/migrations.ts`). + * + * Immich integration: per-user credentials, the linked-asset table, and the + * Photos addon tile (disabled by default). + */ +export class Migration20200101004300_users_add_immich_url extends Migration { + override name = 'Migration20200101004300_users_add_immich_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_url', `immich_url TEXT`); + await addColumnIfMissing(this, 'users', 'immich_api_key', `immich_api_key TEXT`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_asset_id TEXT NOT NULL, + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_asset_id) + ) + `); + + await execBestEffort( + this, + `INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, + ['memories', 'Photos', 'trip', 'Image', 0, 7], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004400_create_file_links.ts b/server/src/db/migrations/Migration20200101004400_create_file_links.ts new file mode 100644 index 000000000..47168a494 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004400_create_file_links.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 44 (`db/migrations.ts`). + */ +export class Migration20200101004400_create_file_links extends Migration { + override name = 'Migration20200101004400_create_file_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS file_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(file_id, reservation_id), + UNIQUE(file_id, assignment_id), + UNIQUE(file_id, place_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts new file mode 100644 index 000000000..f0b707fbd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 45 (`db/migrations.ts`). + */ +export class Migration20200101004500_reservations_add_day_plan_position extends Migration { + override name = 'Migration20200101004500_reservations_add_day_plan_position'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'day_plan_position', `day_plan_position REAL DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts new file mode 100644 index 000000000..e67ecb690 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 46 (`db/migrations.ts`). + */ +export class Migration20200101004600_budget_items_add_paid_by_user_id extends Migration { + override name = 'Migration20200101004600_budget_items_add_paid_by_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'paid_by_user_id', `paid_by_user_id INTEGER REFERENCES users(id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts new file mode 100644 index 000000000..c0d85e6a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 47 (`db/migrations.ts`). + */ +export class Migration20200101004700_bucket_list_add_target_date extends Migration { + override name = 'Migration20200101004700_bucket_list_add_target_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'target_date', `target_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts new file mode 100644 index 000000000..e4b3650cf --- /dev/null +++ b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 48 (`db/migrations.ts`). + */ +export class Migration20200101004800_create_notification_preferences extends Migration { + override name = 'Migration20200101004800_create_notification_preferences'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notify_trip_invite INTEGER DEFAULT 1, + notify_booking_change INTEGER DEFAULT 1, + notify_trip_reminder INTEGER DEFAULT 1, + notify_vacay_invite INTEGER DEFAULT 1, + notify_photos_shared INTEGER DEFAULT 1, + notify_collab_message INTEGER DEFAULT 1, + notify_packing_tagged INTEGER DEFAULT 1, + notify_webhook INTEGER DEFAULT 0, + UNIQUE(user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts new file mode 100644 index 000000000..e049bcb43 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 49 (`db/migrations.ts`). + */ +export class Migration20200101004900_notification_preferences_add_notify_vacay_invite extends Migration { + override name = 'Migration20200101004900_notification_preferences_add_notify_vacay_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_vacay_invite', + `notify_vacay_invite INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_photos_shared', + `notify_photos_shared INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_collab_message', + `notify_collab_message INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_packing_tagged', + `notify_packing_tagged INTEGER DEFAULT 1`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts new file mode 100644 index 000000000..2a63323e8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 50 (`db/migrations.ts`). + */ +export class Migration20200101005000_create_share_tokens extends Migration { + override name = 'Migration20200101005000_create_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + share_map INTEGER DEFAULT 1, + share_bookings INTEGER DEFAULT 1, + share_packing INTEGER DEFAULT 0, + share_budget INTEGER DEFAULT 0, + share_collab INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts new file mode 100644 index 000000000..5db4db8d7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 51 (`db/migrations.ts`). + */ +export class Migration20200101005100_share_tokens_add_share_map extends Migration { + override name = 'Migration20200101005100_share_tokens_add_share_map'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'share_map', `share_map INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_bookings', `share_bookings INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_packing', `share_packing INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_budget', `share_budget INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_collab', `share_collab INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101005200_create_audit_log.ts b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts new file mode 100644 index 000000000..c9cd6f5c5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 52 (`db/migrations.ts`). + */ +export class Migration20200101005200_create_audit_log extends Migration { + override name = 'Migration20200101005200_create_audit_log'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts new file mode 100644 index 000000000..af8e499fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 53 (`db/migrations.ts`). + */ +export class Migration20200101005300_users_add_mfa_backup_codes extends Migration { + override name = 'Migration20200101005300_users_add_mfa_backup_codes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_backup_codes', `mfa_backup_codes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts new file mode 100644 index 000000000..33c9893ff --- /dev/null +++ b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 54 (`db/migrations.ts`). + * + * MCP long-lived API tokens + */ +export class Migration20200101005400_mcp_long_lived_api_tokens extends Migration { + override name = 'Migration20200101005400_mcp_long_lived_api_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS mcp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts new file mode 100644 index 000000000..2928316a5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 55 (`db/migrations.ts`). + * + * MCP addon entry. + */ +export class Migration20200101005500_mcp_addon_entry extends Migration { + override name = 'Migration20200101005500_mcp_addon_entry'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts new file mode 100644 index 000000000..4ccbfcbe5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 56 (`db/migrations.ts`). + * + * Index on mcp_tokens.token_hash + */ +export class Migration20200101005600_index_on_mcp_tokens extends Migration { + override name = 'Migration20200101005600_index_on_mcp_tokens'; + + override up(): void { + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts new file mode 100644 index 000000000..8567cdafa --- /dev/null +++ b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts @@ -0,0 +1,15 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 57 (`db/migrations.ts`). + * + * Ensure MCP addon type is 'integration'. + */ +export class Migration20200101005700_ensure_mcp_addon_type_is_integration extends Migration { + override name = 'Migration20200101005700_ensure_mcp_addon_type_is_integration'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET type = 'integration' WHERE id = 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts new file mode 100644 index 000000000..15ab1cfc9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 58 (`db/migrations.ts`). + */ +export class Migration20200101005800_places_add_route_geometry extends Migration { + override name = 'Migration20200101005800_places_add_route_geometry'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_geometry', `route_geometry TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts new file mode 100644 index 000000000..f25e36b57 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 59 (`db/migrations.ts`). + */ +export class Migration20200101005900_users_add_must_change_password extends Migration { + override name = 'Migration20200101005900_users_add_must_change_password'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'must_change_password', `must_change_password INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts new file mode 100644 index 000000000..31d6a8202 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 60 (`db/migrations.ts`). + */ +export class Migration20200101010000_trips_add_reminder_days extends Migration { + override name = 'Migration20200101010000_trips_add_reminder_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'reminder_days', `reminder_days INTEGER DEFAULT 3`); + } +} diff --git a/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts new file mode 100644 index 000000000..294fba686 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 61 (`db/migrations.ts`). + * + * Encrypt any plaintext oidc_client_secret left in app_settings. + */ +export class Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left extends Migration { + override name = 'Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'oidc_client_secret'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts new file mode 100644 index 000000000..22c0bd15a --- /dev/null +++ b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 62 (`db/migrations.ts`). + * + * Encrypt any plaintext smtp_pass left in app_settings. + */ +export class Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in extends Migration { + override name = 'Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'smtp_pass'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts new file mode 100644 index 000000000..2fbd64f76 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts @@ -0,0 +1,23 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 63 (`db/migrations.ts`). + * + * Encrypt any plaintext immich_api_key values in the users table. + */ +export class Migration20200101010300_encrypt_any_plaintext_immich_api_key_values extends Migration { + override name = 'Migration20200101010300_encrypt_any_plaintext_immich_api_key_values'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'`, + )) as { id: number; immich_api_key: string }[]; + for (const row of rows) { + await this.execute(`UPDATE users SET immich_api_key = ? WHERE id = ?`, [ + encrypt_api_key(row.immich_api_key), + row.id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts new file mode 100644 index 000000000..a411629c0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 64 (`db/migrations.ts`). + */ +export class Migration20200101010400_budget_items_add_expense_date extends Migration { + override name = 'Migration20200101010400_budget_items_add_expense_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'expense_date', `expense_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts new file mode 100644 index 000000000..e378a8816 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 65 (`db/migrations.ts`). + */ +export class Migration20200101010500_create_trip_album_links extends Migration { + override name = 'Migration20200101010500_create_trip_album_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_album_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010600_create_notifications.ts b/server/src/db/migrations/Migration20200101010600_create_notifications.ts new file mode 100644 index 000000000..8d55d129d --- /dev/null +++ b/server/src/db/migrations/Migration20200101010600_create_notifications.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 66 (`db/migrations.ts`). + */ +export class Migration20200101010600_create_notifications extends Migration { + override name = 'Migration20200101010600_create_notifications'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts new file mode 100644 index 000000000..1e8585439 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts @@ -0,0 +1,54 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) +`; + +/** + * Legacy migration step 67 (`db/migrations.ts`). + * + * Normalize trip_photos to the provider-based schema used by current routes. + * The table arrived in two shapes — Immich-only (`immich_asset_id`) and the + * provider-aware one — so the rebuild reads whichever columns are actually + * present and fills the rest with the Immich defaults. + */ +export class Migration20200101010700_create_trip_photos extends Migration { + override name = 'Migration20200101010700_create_trip_photos'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_photos'))) { + await this.execute(`CREATE TABLE trip_photos (${TRIP_PHOTOS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_photos'); + const assetSource = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + if (!assetSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_photos_new (${TRIP_PHOTOS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts new file mode 100644 index 000000000..1cc875bef --- /dev/null +++ b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts @@ -0,0 +1,56 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const ALBUM_LINKS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) +`; + +/** + * Legacy migration step 68 (`db/migrations.ts`). + * + * Normalize trip_album_links to the provider + album_id schema used by current + * routes, reading whichever of the two historical shapes the database has. + */ +export class Migration20200101010800_create_trip_album_links extends Migration { + override name = 'Migration20200101010800_create_trip_album_links'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_album_links'))) { + await this.execute(`CREATE TABLE trip_album_links (${ALBUM_LINKS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_album_links'); + const albumIdSource = names.has('album_id') ? 'album_id' : names.has('immich_album_id') ? 'immich_album_id' : null; + if (!albumIdSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const albumNameExpr = names.has('album_name') ? `COALESCE(album_name, '')` : `''`; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_album_links_new (${ALBUM_LINKS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + await this.execute(`DROP TABLE trip_album_links`); + await this.execute(`ALTER TABLE trip_album_links_new RENAME TO trip_album_links`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts new file mode 100644 index 000000000..a381f85ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 69 (`db/migrations.ts`). + */ +export class Migration20200101010900_users_add_synology_url extends Migration { + override name = 'Migration20200101010900_users_add_synology_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'synology_url', `synology_url TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_username', `synology_username TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_password', `synology_password TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_sid', `synology_sid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101011000_step_070.ts b/server/src/db/migrations/Migration20200101011000_step_070.ts new file mode 100644 index 000000000..c6715abce --- /dev/null +++ b/server/src/db/migrations/Migration20200101011000_step_070.ts @@ -0,0 +1,97 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const PROVIDER_FIELDS: [string, string, string, string, string, number, number, string | null, string, number][] = [ + [ + 'synologyphotos', + 'synology_url', + 'providerUrl', + 'url', + 'https://synology.example.com', + 1, + 0, + 'synology_url', + 'synology_url', + 0, + ], + [ + 'synologyphotos', + 'synology_username', + 'providerUsername', + 'text', + 'Username', + 1, + 0, + 'synology_username', + 'synology_username', + 1, + ], + [ + 'synologyphotos', + 'synology_password', + 'providerPassword', + 'password', + 'Password', + 1, + 1, + null, + 'synology_password', + 2, + ], +]; + +/** + * Legacy migration step 70 (`db/migrations.ts`). + * + * Seed the Synology Photos provider and its fields in existing databases. Both + * statements are skipped when the provider tables do not exist yet — on those + * installs the seeder creates the rows instead. + */ +export class Migration20200101011000_step_070 extends Migration { + override name = 'Migration20200101011000_step_070'; + + override async up(): Promise { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + sort_order = excluded.sort_order + `, + [ + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + 1, + ], + ); + + for (const field of PROVIDER_FIELDS) { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `, + field, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101011100_step_071.ts b/server/src/db/migrations/Migration20200101011100_step_071.ts new file mode 100644 index 000000000..a72836c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011100_step_071.ts @@ -0,0 +1,18 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 71 (`db/migrations.ts`). + * + * Remove the stored config column from photo_providers now that it is generated + * from the provider id. + */ +export class Migration20200101011100_step_071 extends Migration { + override name = 'Migration20200101011100_step_071'; + + override async up(): Promise { + const names = await columnNames(this, 'photo_providers'); + if (!names.has('config')) return; + await this.execute(`ALTER TABLE photo_providers DROP COLUMN config`); + } +} diff --git a/server/src/db/migrations/Migration20200101011200_step_072.ts b/server/src/db/migrations/Migration20200101011200_step_072.ts new file mode 100644 index 000000000..cd5bdc70d --- /dev/null +++ b/server/src/db/migrations/Migration20200101011200_step_072.ts @@ -0,0 +1,22 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 72 (`db/migrations.ts`). + * + * The Immich-only column names become provider-neutral ones. A database that + * already went through step 67/68 is left alone. + */ +export class Migration20200101011200_step_072 extends Migration { + override name = 'Migration20200101011200_step_072'; + + override async up(): Promise { + const names = await columnNames(this, 'trip_photos'); + if (names.has('asset_id') && !names.has('immich_asset_id')) return; + + await this.execute('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); + await this.execute('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); + } +} diff --git a/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts new file mode 100644 index 000000000..5938e91e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 73 (`db/migrations.ts`). + */ +export class Migration20200101011300_trip_photos_add_album_link_id extends Migration { + override name = 'Migration20200101011300_trip_photos_add_album_link_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_photos', + 'album_link_id', + `album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011400_todo_items.ts b/server/src/db/migrations/Migration20200101011400_todo_items.ts new file mode 100644 index 000000000..36c665e52 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011400_todo_items.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 74 (`db/migrations.ts`). + * + * Migration 68: Todo items + */ +export class Migration20200101011400_todo_items extends Migration { + override name = 'Migration20200101011400_todo_items'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + due_date TEXT, + description TEXT, + assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + priority INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011500_backfill_addons.ts b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts new file mode 100644 index 000000000..62c80f828 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts @@ -0,0 +1,13 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 75 (`db/migrations.ts`). + */ +export class Migration20200101011500_backfill_addons extends Migration { + override name = 'Migration20200101011500_backfill_addons'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET enabled = 0 WHERE id = 'memories'`); + } +} diff --git a/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts new file mode 100644 index 000000000..be30d43fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 76 (`db/migrations.ts`). + * + * Migration 69: Place region cache for sub-national Atlas regions + */ +export class Migration20200101011600_place_region_cache_for_sub_national_atlas extends Migration { + override name = 'Migration20200101011600_place_region_cache_for_sub_national_atlas'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_regions ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts new file mode 100644 index 000000000..923328451 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 77 (`db/migrations.ts`). + */ +export class Migration20200101011700_create_visited_regions extends Migration { + override name = 'Migration20200101011700_create_visited_regions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts new file mode 100644 index 000000000..c91983703 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts @@ -0,0 +1,69 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** Old single-row-per-user columns → the event_type they become. */ +const EVENT_COLUMNS: Record = { + trip_invite: 'notify_trip_invite', + booking_change: 'notify_booking_change', + trip_reminder: 'notify_trip_reminder', + vacay_invite: 'notify_vacay_invite', + photos_shared: 'notify_photos_shared', + collab_message: 'notify_collab_message', + packing_tagged: 'notify_packing_tagged', +}; + +/** + * Legacy migration step 78 (`db/migrations.ts`). + * + * Migration 71: Normalized per-user per-channel notification preferences. + * + * Only the *disabled* combinations are written: a missing row means enabled, so + * copying the defaults across would bloat the table for no change in behaviour. + */ +export class Migration20200101011800_normalized_per_user_per_channel_notification_preferences extends Migration { + override name = 'Migration20200101011800_normalized_per_user_per_channel_notification_preferences'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + // The old table does not exist on fresh installs. + const oldPrefs = (await tableExists(this, 'notification_preferences')) + ? ((await this.execute(`SELECT * FROM notification_preferences`)) as Record[]) + : []; + + for (const row of oldPrefs) { + const userId = row.user_id; + const webhookEnabled = row.notify_webhook ?? 0; + for (const [eventType, col] of Object.entries(EVENT_COLUMNS)) { + const emailEnabled = row[col] ?? 1; + if (!emailEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'email', 0], + ); + } + if (!webhookEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'webhook', 0], + ); + } + } + } + + // Copy the existing single-channel setting to the new plural key. + await this.execute(` + INSERT OR IGNORE INTO app_settings (key, value) + SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts new file mode 100644 index 000000000..7d09b1313 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 79 (`db/migrations.ts`). + * + * Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71) + */ +export class Migration20200101011900_drop_the_old_notification_preferences_table extends Migration { + override name = 'Migration20200101011900_drop_the_old_notification_preferences_table'; + + override up(): void { + this.addSql(`DROP TABLE IF EXISTS notification_preferences`); + } +} diff --git a/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts new file mode 100644 index 000000000..bf8b66bed --- /dev/null +++ b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 80 (`db/migrations.ts`). + * + * Migration 73: Add reservation_id to budget_items for linking budget entries to reservations + */ +export class Migration20200101012000_add_reservation_id_to_budget_items_for extends Migration { + override name = 'Migration20200101012000_add_reservation_id_to_budget_items_for'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts new file mode 100644 index 000000000..d7d4a962a --- /dev/null +++ b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts @@ -0,0 +1,43 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 81 (`db/migrations.ts`). + * + * Migration 74: Add quantity to packing_items + user_id to packing_bags + + * bag_members table. The single `user_id` becomes the first row of the new + * membership table; the column itself stays for the steps that still read it. + */ +export class Migration20200101012100_add_quantity_to_packing_items_user_id extends Migration { + override name = 'Migration20200101012100_add_quantity_to_packing_items_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`); + await addColumnIfMissing( + this, + 'packing_bags', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bag_members ( + bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (bag_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id)`); + + const bagsWithUser = (await this.execute(`SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL`)) as { + id: number; + user_id: number; + }[]; + for (const bag of bagsWithUser) { + await this.execute(`INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)`, [ + bag.id, + bag.user_id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts new file mode 100644 index 000000000..ccb69e92d --- /dev/null +++ b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts @@ -0,0 +1,55 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A reservation that still carries the old single, trip-wide plan position. */ +interface PositionedReservation { + id: number; + trip_id: number; + reservation_time: string | null; + reservation_end_time: string | null; + day_plan_position: number; +} + +/** + * Legacy migration step 82 (`db/migrations.ts`). + * + * Per-day positions for multi-day reservations: one global position becomes one + * row per day the reservation spans, so a stay can sit in a different slot on + * each of its days. + */ +export class Migration20200101012200_per_day_positions_for_multi_day_reservations extends Migration { + override name = 'Migration20200101012200_per_day_positions_for_multi_day_reservations'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_day_positions ( + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + position REAL NOT NULL, + PRIMARY KEY (reservation_id, day_id) + ) + `); + + const reservations = (await this.execute( + `SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL`, + )) as PositionedReservation[]; + + for (const r of reservations) { + const startDate = r.reservation_time?.split('T')[0]; + if (!startDate) continue; + const endDate = r.reservation_end_time?.split('T')[0] || startDate; + + const matchingDays = (await this.execute(`SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?`, [ + r.trip_id, + startDate, + endDate, + ])) as { id: number }[]; + + for (const day of matchingDays) { + await this.execute( + `INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)`, + [r.id, day.id, r.day_plan_position], + ); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts new file mode 100644 index 000000000..04906bf53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 83 (`db/migrations.ts`). + * + * Budget category ordering. Existing categories are seeded in alphabetical order + * per trip, which is the order they were displayed in before this table existed. + */ +export class Migration20200101012300_budget_category_ordering extends Migration { + override name = 'Migration20200101012300_budget_category_ordering'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_category_order ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trip_id, category) + ) + `); + + const rows = (await this.execute( + `SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category`, + )) as { trip_id: number; category: string }[]; + + let lastTripId = -1; + let index = 0; + for (const row of rows) { + if (row.trip_id !== lastTripId) { + lastTripId = row.trip_id; + index = 0; + } + await this.execute( + `INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)`, + [row.trip_id, row.category, index++], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts new file mode 100644 index 000000000..3218f6d25 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 84 (`db/migrations.ts`). + * + * Naver list import addon (default off). + */ +export class Migration20200101012400_naver_list_import_addon extends Migration { + override name = 'Migration20200101012400_naver_list_import_addon'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012500_oauth_2.ts b/server/src/db/migrations/Migration20200101012500_oauth_2.ts new file mode 100644 index 000000000..f60c135b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012500_oauth_2.ts @@ -0,0 +1,61 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 85 (`db/migrations.ts`). + * + * Migration: OAuth 2.1 clients, consents, and tokens for MCP + */ +export class Migration20200101012500_oauth_2 extends Migration { + override name = 'Migration20200101012500_oauth_2'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes TEXT NOT NULL DEFAULT '[]', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(client_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token_hash TEXT UNIQUE NOT NULL, + refresh_token_hash TEXT UNIQUE NOT NULL, + scopes TEXT NOT NULL DEFAULT '[]', + access_token_expires_at DATETIME NOT NULL, + refresh_token_expires_at DATETIME NOT NULL, + revoked_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts new file mode 100644 index 000000000..788770710 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 86 (`db/migrations.ts`). + * + * Migration: Refresh-token rotation chain tracking for replay detection + */ +export class Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay extends Migration { + override name = 'Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_tokens', + 'parent_token_id', + `parent_token_id INTEGER REFERENCES oauth_tokens(id)`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts new file mode 100644 index 000000000..e43e84862 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 87 (`db/migrations.ts`). + * + * Migration: Public client support for browser-initiated dynamic registration (DCR) + */ +export class Migration20200101012700_public_client_support_for_browser_initiated_dynamic extends Migration { + override name = 'Migration20200101012700_public_client_support_for_browser_initiated_dynamic'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_clients', 'is_public', `is_public INTEGER NOT NULL DEFAULT 0`); + + await addColumnIfMissing(this, 'oauth_clients', 'created_via', `created_via TEXT NOT NULL DEFAULT 'settings_ui'`); + } +} diff --git a/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts new file mode 100644 index 000000000..9a0d777e0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 88 (`db/migrations.ts`). + * + * Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients. + * + * The legacy step ran outside a transaction so it could `PRAGMA foreign_keys = + * OFF` around the drop-and-rename — SQLite ignores that pragma inside an open + * transaction, and `oauth_tokens`/`oauth_consents` point at this table. Here the + * migration stays transactional and uses `defer_foreign_keys` instead, which is + * the in-transaction equivalent: enforcement is postponed to the commit, by + * which point the rename has made every reference valid again. + */ +export class Migration20200101012800_make_oauth_clients extends Migration { + override name = 'Migration20200101012800_make_oauth_clients'; + + override async up(): Promise { + await this.execute(`PRAGMA defer_foreign_keys = ON`); + await this.execute(` + CREATE TABLE IF NOT EXISTS oauth_clients_new ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_public INTEGER NOT NULL DEFAULT 0, + created_via TEXT NOT NULL DEFAULT 'settings_ui' + ) + `); + await this.execute( + `INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`, + ); + await this.execute(`DROP TABLE oauth_clients`); + await this.execute(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + await this.execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts new file mode 100644 index 000000000..46ecd587e --- /dev/null +++ b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 89 (`db/migrations.ts`). + * + * Add OTP field, skip_ssl column, device_id (did) column, and hint column for + * Synology Photos. + */ +export class Migration20200101012900_add_otp_field_skip_ssl_column_device extends Migration { + override name = 'Migration20200101012900_add_otp_field_skip_ssl_column_device'; + + override async up(): Promise { + await addColumnIfMissing(this, 'photo_provider_fields', 'hint', `hint TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) + `); + + await addColumnIfMissing(this, 'users', 'synology_skip_ssl', `synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing(this, 'users', 'synology_did', `synology_did TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) + `); + + await this.execute(` + UPDATE photo_provider_fields + SET hint = 'providerUrlHintSynology' + WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101013000_journey_addon.ts b/server/src/db/migrations/Migration20200101013000_journey_addon.ts new file mode 100644 index 000000000..83cce59c3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013000_journey_addon.ts @@ -0,0 +1,121 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 90 (`db/migrations.ts`). + * + * Migration 84: Journey addon — trip tracking & travel journal. Registered + * disabled: the addon is opt-in. + */ +export class Migration20200101013000_journey_addon extends Migration { + override name = 'Migration20200101013000_journey_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journeys ( + id TEXT PRIMARY KEY, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + cover_image TEXT, + status TEXT NOT NULL DEFAULT 'draft', + started_at TEXT, + ended_at TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + public_token TEXT UNIQUE, + settings TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_checkins ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + address TEXT, + country_code TEXT, + notes TEXT, + checked_in_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_entries ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_date TEXT NOT NULL, + title TEXT, + body TEXT, + mood TEXT, + weather TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL, + storage_type TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + file_path TEXT, + thumbnail_path TEXT, + original_name TEXT, + mime_type TEXT, + size_bytes INTEGER, + caption TEXT, + taken_at TEXT, + lat REAL, + lng REAL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_location_trail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lng REAL NOT NULL, + altitude REAL, + accuracy REAL, + recorded_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'dawarich' + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at)`, + ); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013100_journal.ts b/server/src/db/migrations/Migration20200101013100_journal.ts new file mode 100644 index 000000000..822827706 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013100_journal.ts @@ -0,0 +1,40 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 91 (`db/migrations.ts`). + * + * Migration 85: Journal — richer entry fields for magazine-style design + */ +export class Migration20200101013100_journal extends Migration { + override name = 'Migration20200101013100_journal'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'highlight_tags', `highlight_tags TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'visibility', `visibility TEXT NOT NULL DEFAULT 'private'`); + + await addColumnIfMissing(this, 'journey_entries', 'hero_photo_id', `hero_photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'color_accent', `color_accent TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'place_name', `place_name TEXT`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'journey_entries', 'lat', `lat REAL`); + + await addColumnIfMissing(this, 'journey_entries', 'lng', `lng REAL`); + + await addColumnIfMissing(this, 'journey_checkins', 'photo_id', `photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_photos', 'width', `width INTEGER`); + + await addColumnIfMissing(this, 'journey_photos', 'height', `height INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts new file mode 100644 index 000000000..55e730673 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts @@ -0,0 +1,53 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 92 (`db/migrations.ts`). + * + * Migration 86: Journey multi-trip support + sharing/collaboration + */ +export class Migration20200101013200_journey_multi_trip_support_sharing_collaboration extends Migration { + override name = 'Migration20200101013200_journey_multi_trip_support_sharing_collaboration'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_trips ( + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + PRIMARY KEY (journey_id, trip_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer', + invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(journey_id, user_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing( + this, + 'journey_checkins', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts new file mode 100644 index 000000000..573349c07 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts @@ -0,0 +1,226 @@ +import { bestEffort, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** A row from whichever shape of the old journey tables this database had. */ +type LegacyRow = Record; + +const NEW_TABLES = [ + `CREATE TABLE journeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + cover_gradient TEXT, + status TEXT DEFAULT 'draft', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_trips ( + journey_id INTEGER NOT NULL, + trip_id INTEGER NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, trip_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + source_trip_id INTEGER, + source_place_id INTEGER, + author_id INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT, + story TEXT, + entry_date TEXT NOT NULL, + entry_time TEXT, + location_name TEXT, + location_lat REAL, + location_lng REAL, + mood TEXT, + weather TEXT, + tags TEXT, + visibility TEXT DEFAULT 'private', + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL, + FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL, + FOREIGN KEY (author_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_contributors ( + journey_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, user_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, +]; + +const NEW_INDEXES = [ + `CREATE INDEX idx_journeys_user ON journeys(user_id)`, + `CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date)`, + `CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id)`, + `CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`, + `CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id)`, + `CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id)`, +]; + +/** + * Legacy migration step 93 (`db/migrations.ts`). + * + * Migration 87: Journey rebuild — new schema with trip sync. + * + * Step 90's journeys were TEXT-keyed and single-trip; this replaces them with + * integer keys, a journey↔trip join table and contributors. The old rows are + * read into memory, the tables dropped, and the rows re-inserted against the new + * shape — so the old TEXT ids have to be mapped to the new autoincrement ones. + */ +export class Migration20200101013300_journey_rebuild extends Migration { + override name = 'Migration20200101013300_journey_rebuild'; + + /** SQLite's `lastInsertRowid`, which `execute()` does not surface for raw SQL. */ + private async lastInsertId(): Promise { + const rows = (await this.execute(`SELECT last_insert_rowid() AS id`)) as { id: number }[]; + return Number(rows[0].id); + } + + override async up(): Promise { + let oldJourneys: LegacyRow[] = []; + let oldEntries: LegacyRow[] = []; + let oldPhotos: LegacyRow[] = []; + + if (await tableExists(this, 'journeys')) { + await bestEffort(async () => { + oldJourneys = (await this.execute(`SELECT * FROM journeys`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldEntries = (await this.execute(`SELECT * FROM journey_entries`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldPhotos = (await this.execute(`SELECT * FROM journey_photos`)) as LegacyRow[]; + }); + + for (const table of [ + 'journey_location_trail', + 'journey_photos', + 'journey_entries', + 'journey_checkins', + 'journey_members', + 'journey_trips', + 'journeys', + ]) { + await this.execute(`DROP TABLE IF EXISTS ${table}`); + } + } + + for (const ddl of NEW_TABLES) await this.execute(ddl); + for (const ddl of NEW_INDEXES) await this.execute(ddl); + + if (oldJourneys.length === 0) return; + + const now = Date.now(); + const toMillis = (value: string | number | null | undefined): number => (value ? new Date(value).getTime() : now); + + const journeyIdMap = new Map(); + for (const j of oldJourneys) { + await this.execute( + `INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + [ + j.user_id, + j.title || 'Untitled Journey', + j.description || null, + j.status || 'draft', + toMillis(j.created_at), + toMillis(j.updated_at), + ], + ); + const newJourneyId = await this.lastInsertId(); + journeyIdMap.set(j.id!, newJourneyId); + + await this.execute( + `INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)`, + [newJourneyId, j.user_id, now], + ); + + if (j.trip_id) { + await bestEffort(async () => { + await this.execute(`INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)`, [ + newJourneyId, + j.trip_id, + now, + ]); + }); + } + } + + const entryIdMap = new Map(); + for (const e of oldEntries) { + const newJourneyId = journeyIdMap.get(e.journey_id!); + if (!newJourneyId) continue; + + const authorId = e.user_id || oldJourneys.find((j) => j.id === e.journey_id)?.user_id || 1; + await this.execute( + `INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newJourneyId, + authorId, + e.title || null, + e.body || null, + e.entry_date || new Date().toISOString().split('T')[0], + e.place_name || null, + e.lat || null, + e.lng || null, + e.mood || null, + e.weather || null, + e.visibility || 'private', + e.sort_order || 0, + toMillis(e.created_at), + toMillis(e.updated_at), + ], + ); + entryIdMap.set(e.id!, await this.lastInsertId()); + } + + for (const p of oldPhotos) { + const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null; + if (!newEntryId || !p.file_path) continue; + + await this.execute( + `INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newEntryId, + p.file_path, + p.thumbnail_path || null, + p.caption || null, + p.sort_order || 0, + p.width || null, + p.height || null, + toMillis(p.created_at), + ], + ); + } + + console.log( + `[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013400_journey_photos.ts b/server/src/db/migrations/Migration20200101013400_journey_photos.ts new file mode 100644 index 000000000..57eebafa7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013400_journey_photos.ts @@ -0,0 +1,54 @@ +import { bestEffort, columnNames, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 94 (`db/migrations.ts`). + * + * Migration 88: Journey photos — provider support (Immich/Synology). + * + * Every part of this step was swallowed on failure in the original, and it is + * kept that way: the rebuild below assumes the integer-keyed `journey_photos` + * shape, so on a database still carrying step 90's TEXT-keyed table the copy + * fails and the later journey rebuild (step 93) is what actually fixes it. + */ +export class Migration20200101013400_journey_photos extends Migration { + override name = 'Migration20200101013400_journey_photos'; + + override async up(): Promise { + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN asset_id TEXT`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1`); + + // file_path was NOT NULL — recreate the table to make it nullable. + const names = await columnNames(this, 'journey_photos'); + if (!names.has('provider')) return; + + await bestEffort(async () => { + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + provider TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + owner_id INTEGER REFERENCES users(id), + file_path TEXT, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute( + `INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos`, + ); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`); + }); + } +} diff --git a/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts new file mode 100644 index 000000000..a389a57fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 95 (`db/migrations.ts`). + * + * Migration 89: Journey cover image + */ +export class Migration20200101013500_journey_cover_image extends Migration { + override name = 'Migration20200101013500_journey_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts new file mode 100644 index 000000000..3e47376da --- /dev/null +++ b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 96 (`db/migrations.ts`). + * + * Migration 90: Pros/Cons for journey entries + */ +export class Migration20200101013600_pros_cons_for_journey_entries extends Migration { + override name = 'Migration20200101013600_pros_cons_for_journey_entries'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'pros_cons', `pros_cons TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts new file mode 100644 index 000000000..f17a08caf --- /dev/null +++ b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 97 (`db/migrations.ts`). + * + * Migration 91: Journey share tokens + */ +export class Migration20200101013700_journey_share_tokens extends Migration { + override name = 'Migration20200101013700_journey_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL, + share_timeline INTEGER DEFAULT 1, + share_gallery INTEGER DEFAULT 1, + share_map INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts new file mode 100644 index 000000000..5a2f48ba4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 98 (`db/migrations.ts`). + * + * Migration: Vacay week_start setting (0=Sunday, 1=Monday default) + */ +export class Migration20200101013800_vacay_week_start_setting extends Migration { + override name = 'Migration20200101013800_vacay_week_start_setting'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'week_start', `week_start INTEGER NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts new file mode 100644 index 000000000..01aa9f374 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts @@ -0,0 +1,136 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_NEW = ` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + shared INTEGER NOT NULL DEFAULT 1, + album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, photo_id) + ) +`; + +/** + * Legacy migration step 99 (`db/migrations.ts`). + * + * Unified Photo Provider Abstraction Layer (#584). A central `trek_photos` + * registry; `trip_photos` and `journey_photos` stop carrying provider details + * and reference it by `photo_id` instead. + * + * `trip_photos` arrived in several shapes over the releases, so the copy reads + * whichever columns the database actually has. + */ +export class Migration20200101013900_unified_photo_provider_abstraction_layer extends Migration { + override name = 'Migration20200101013900_unified_photo_provider_abstraction_layer'; + + override async up(): Promise { + // 1. The central photo registry. + await this.execute(` + CREATE TABLE IF NOT EXISTS trek_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + asset_id TEXT, + owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + file_path TEXT, + thumbnail_path TEXT, + width INTEGER, + height INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)`); + + // 2. trip_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'trip_photos')) { + const names = await columnNames(this, 'trip_photos'); + const hasProvider = names.has('provider'); + const assetCol = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + + if (assetCol) { + const providerExpr = hasProvider ? 'provider' : `'immich'`; + // Qualified alias needed in the JOIN, where both tables have `provider`. + const providerJoinExpr = hasProvider ? 'tp.provider' : `'immich'`; + const sharedExpr = names.has('shared') ? 'shared' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + const albumLinkExpr = names.has('album_link_id') ? 'album_link_id' : 'NULL'; + + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) + SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr} + FROM trip_photos + WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != '' + `); + await this.execute(TRIP_PHOTOS_NEW); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) + SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr} + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id + `); + } else { + // No asset column at all — recreate empty. + await this.execute(TRIP_PHOTOS_NEW); + } + + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)`); + } + + // 3. journey_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'journey_photos')) { + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) + SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at + FROM journey_photos + WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' + `); + // Local photos are unique per row, so no de-duplication. + await this.execute(` + INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) + SELECT 'local', file_path, thumbnail_path, width, height, created_at + FROM journey_photos + WHERE provider = 'local' AND file_path IS NOT NULL + `); + + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + sort_order INTEGER DEFAULT 0, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id + WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path + WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL + `); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts new file mode 100644 index 000000000..17295bddb --- /dev/null +++ b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 100 (`db/migrations.ts`). + * + * Migration 99: hide_skeletons per-user setting on journey_contributors + */ +export class Migration20200101014000_hide_skeletons_per_user_setting_on_journey extends Migration { + override name = 'Migration20200101014000_hide_skeletons_per_user_setting_on_journey'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'journey_contributors', + 'hide_skeletons', + `hide_skeletons INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts new file mode 100644 index 000000000..9a9d5d149 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 101 (`db/migrations.ts`). + * + * Migration 100: Idempotency keys for offline mutation replay + */ +export class Migration20200101014100_idempotency_keys_for_offline_mutation_replay extends Migration { + override name = 'Migration20200101014100_idempotency_keys_for_offline_mutation_replay'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts new file mode 100644 index 000000000..16623322a --- /dev/null +++ b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 102 (`db/migrations.ts`). + * + * Migration 101: Enable naver_list_import by default. + */ +export class Migration20200101014200_enable_naver_list_import_by_default extends Migration { + override name = 'Migration20200101014200_enable_naver_list_import_by_default'; + + override up(): void { + this.addSql(`UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts new file mode 100644 index 000000000..394f7cb0f --- /dev/null +++ b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 103 (`db/migrations.ts`). + * + * Migration 102: Add check_in_end column for check-in time ranges + */ +export class Migration20200101014300_add_check_in_end_column_for_check extends Migration { + override name = 'Migration20200101014300_add_check_in_end_column_for_check'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in_end', `check_in_end TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101014400_system_notices.ts b/server/src/db/migrations/Migration20200101014400_system_notices.ts new file mode 100644 index 000000000..d32be4e77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014400_system_notices.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 104 (`db/migrations.ts`). + * + * Migration 103: System notices — user tracking columns + dismissals table + */ +export class Migration20200101014400_system_notices extends Migration { + override name = 'Migration20200101014400_system_notices'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'first_seen_version', `first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); + + await addColumnIfMissing(this, 'users', 'login_count', `login_count INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS user_notice_dismissals ( + user_id INTEGER NOT NULL, + notice_id TEXT NOT NULL, + dismissed_at INTEGER NOT NULL, + PRIMARY KEY (user_id, notice_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts new file mode 100644 index 000000000..709326910 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 105 (`db/migrations.ts`). + * + * Migration 104: Passphrase support for Synology shared-album links (#689) + */ +export class Migration20200101014500_passphrase_support_for_synology_shared_album_links extends Migration { + override name = 'Migration20200101014500_passphrase_support_for_synology_shared_album_links'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trip_album_links', 'passphrase', `passphrase TEXT DEFAULT NULL`); + + await addColumnIfMissing(this, 'trek_photos', 'passphrase', `passphrase TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts new file mode 100644 index 000000000..66893d672 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 106 (`db/migrations.ts`). + * + * Migration 105: Persistent Google place photo disk cache registry + */ +export class Migration20200101014600_persistent_google_place_photo_disk_cache_registry extends Migration { + override name = 'Migration20200101014600_persistent_google_place_photo_disk_cache_registry'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS google_place_photo_meta ( + place_id TEXT PRIMARY KEY, + attribution TEXT, + fetched_at INTEGER NOT NULL, + error_at INTEGER + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts new file mode 100644 index 000000000..dfddb1d65 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 107 (`db/migrations.ts`). + * + * Migration 106: Persistent Place Details row cache + */ +export class Migration20200101014700_persistent_place_details_row_cache extends Migration { + override name = 'Migration20200101014700_persistent_place_details_row_cache'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_details_cache ( + place_id TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts new file mode 100644 index 000000000..53254a409 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 108 (`db/migrations.ts`). + * + * Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs. + * + * The legacy step was declared `raw` (run outside a transaction); it is a single + * UPDATE with no pragma requirement, so it runs transactionally here. + */ +export class Migration20200101014800_backfill_expired_signed_google_photo_urls_to extends Migration { + override name = 'Migration20200101014800_backfill_expired_signed_google_photo_urls_to'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND ( + (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') + OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts new file mode 100644 index 000000000..2bc5e7bf1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 109 (`db/migrations.ts`). + * + * Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) + */ +export class Migration20200101014900_disk_cache_metadata_for_remote_provider_photo extends Migration { + override name = 'Migration20200101014900_disk_cache_metadata_for_remote_provider_photo'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( + cache_key TEXT PRIMARY KEY, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts new file mode 100644 index 000000000..7c7a63985 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts @@ -0,0 +1,36 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 110 (`db/migrations.ts`). + * + * Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587 + */ +export class Migration20200101015000_reservation_endpoints extends Migration { + override name = 'Migration20200101015000_reservation_endpoints'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + code TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + timezone TEXT, + local_time TEXT, + local_date TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)`, + ); + + await addColumnIfMissing(this, 'reservations', 'needs_review', `needs_review INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts new file mode 100644 index 000000000..55a2ddd64 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts @@ -0,0 +1,47 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 111 (`db/migrations.ts`). + * + * Migration 110 — link transport reservations to days via day_id / end_day_id + */ +export class Migration20200101015100_reservations_add_end_day_id extends Migration { + override name = 'Migration20200101015100_reservations_add_end_day_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'end_day_id', + `end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL`, + ); + + await this.execute(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + await this.execute(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts new file mode 100644 index 000000000..025c93539 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 112 (`db/migrations.ts`). + * + * Migration 111: opt-in Immich auto-upload — users column only (#730) + * Default is off — uploading to Immich must be an explicit choice, not a + * side effect of having a writable API key. + */ +export class Migration20200101015200_opt_in_immich_auto_upload extends Migration { + override name = 'Migration20200101015200_opt_in_immich_auto_upload'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_auto_upload', `immich_auto_upload INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts new file mode 100644 index 000000000..612eb0e37 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts @@ -0,0 +1,27 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 113 (`db/migrations.ts`). + * + * Migration 112: expose the immich auto-upload toggle in the Settings UI (#730). + * Runs after Immich provider seeding so the FK to photo_providers holds. + */ +export class Migration20200101015300_expose_immich_auto_upload_toggle_in_the extends Migration { + override name = 'Migration20200101015300_expose_immich_auto_upload_toggle_in_the'; + + override async up(): Promise { + if (!(await tableExists(this, 'photo_providers'))) return; + if (!(await tableExists(this, 'photo_provider_fields'))) return; + + const immich = await this.execute(`SELECT 1 AS present FROM photo_providers WHERE id = 'immich' LIMIT 1`); + if (immich.length === 0) return; + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts new file mode 100644 index 000000000..3ef141c09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 114 (`db/migrations.ts`). + * + * Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp + */ +export class Migration20200101015400_rfc_8707_resource_indicators extends Migration { + override name = 'Migration20200101015400_rfc_8707_resource_indicators'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_tokens', 'audience', `audience TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101015500_password_reset.ts b/server/src/db/migrations/Migration20200101015500_password_reset.ts new file mode 100644 index 000000000..8f60a53ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101015500_password_reset.ts @@ -0,0 +1,33 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 115 (`db/migrations.ts`). + * + * Migration: password reset — add password_version for session + * invalidation, and a token table keyed by SHA-256 hash (raw tokens + * never hit the DB). + */ +export class Migration20200101015500_password_reset extends Migration { + override name = 'Migration20200101015500_password_reset'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'password_version', `password_version INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts new file mode 100644 index 000000000..c4b48771d --- /dev/null +++ b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 116 (`db/migrations.ts`). + * + * Migration: todo due-date reminders — track when we last sent a + * reminder for each todo so we don't spam the same notification + * every day the scheduler runs. + */ +export class Migration20200101015600_todo_due_date_reminders extends Migration { + override name = 'Migration20200101015600_todo_due_date_reminders'; + + override async up(): Promise { + await addColumnIfMissing(this, 'todo_items', 'reminded_at', `reminded_at DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts new file mode 100644 index 000000000..48a30cc7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts @@ -0,0 +1,56 @@ +import { addColumnIfMissing, bestEffort, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 117 (`db/migrations.ts`). + * + * Security audit batch 1 — columns + indexes required by several fixes bundled + * into one PR. + * + * - `share_tokens.expires_at`: public share links now get a 90-day TTL by + * default; existing rows stay NULL (= no expiry) so already-published links + * are not silently broken. + * - Missing indexes on high-cardinality query paths (PERF-H1 in the audit): + * every `listTrips()` used to full-scan trips on user_id, and + * notifications/photos/reservations had similar gaps. + */ +export class Migration20200101015700_security_audit_batch_1 extends Migration { + override name = 'Migration20200101015700_security_audit_batch_1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'expires_at', `expires_at TEXT`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token)`); + + // day_accommodations may have either start_day_id/end_day_id or a single + // day_id depending on how far the schema has evolved; build whichever index + // makes sense for the live columns. Non-fatal: the table may not exist on + // very old installs. + await bestEffort(async () => { + const names = await columnNames(this, 'day_accommodations'); + if (names.has('start_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`, + ); + } + if (names.has('end_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`, + ); + } + }); + + // The notifications schema has varied; probe before indexing. + await bestEffort(async () => { + const names = await columnNames(this, 'notifications'); + if (names.has('target') && names.has('scope')) { + await this.execute(`CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)`); + } + }); + } +} diff --git a/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts new file mode 100644 index 000000000..bc549649e --- /dev/null +++ b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts @@ -0,0 +1,42 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 118 (`db/migrations.ts`). + * + * Widen the idempotency_keys primary key to (key, user_id, method, path). The + * middleware lookup was widened in the same audit batch so a reused + * `X-Idempotency-Key` against a different endpoint does not replay the cached + * body of an unrelated request. The old PK was only (key, user_id), so the + * `INSERT OR IGNORE` on the second endpoint silently skipped — the cache never + * stored request B's response and replays re-executed the handler. Rebuilding + * with the widened PK preserves existing rows: the old PK guarantees there are + * no conflicts under the new, strictly looser unique key. + */ +export class Migration20200101015800_widen_idempotency_keys_primary_key_to extends Migration { + override name = 'Migration20200101015800_widen_idempotency_keys_primary_key_to'; + + override async up(): Promise { + if (!(await tableExists(this, 'idempotency_keys'))) return; + + await this.execute(` + CREATE TABLE idempotency_keys_new ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id, method, path) + ) + `); + await this.execute(` + INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at) + SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys + `); + await this.execute(`DROP TABLE idempotency_keys`); + await this.execute(`ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts new file mode 100644 index 000000000..a4c686280 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts @@ -0,0 +1,20 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 119 (`db/migrations.ts`). + * + * SEC-H6: revoke all OAuth tokens issued before audience binding was enforced. + * `mcp/index.ts` now unconditionally checks audience; tokens with + * `audience = NULL` would be permanently rejected by that check, so removing + * them here avoids leaving dead rows and makes the intent clear. + */ +export class Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued extends Migration { + override name = 'Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued'; + + override async up(): Promise { + const names = await columnNames(this, 'oauth_tokens'); + if (!names.has('audience')) return; + await this.execute(`DELETE FROM oauth_tokens WHERE audience IS NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts new file mode 100644 index 000000000..8a5d5b596 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts @@ -0,0 +1,48 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 120 (`db/migrations.ts`). + * + * Remove NOT NULL constraint on day_accommodations.place_id so hotel + * reservations created from the Bookings tab without a linked place can + * still persist their date range. Change ON DELETE CASCADE → SET NULL so + * deleting a place orphans the accommodation row instead of cascading. + */ +export class Migration20200101020000_remove_not_null_constraint_on_day_accommodations extends Migration { + override name = 'Migration20200101020000_remove_not_null_constraint_on_day_accommodations'; + + override up(): void { + this.addSql(` + CREATE TABLE day_accommodations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + INSERT INTO day_accommodations_new + SELECT id, trip_id, place_id, start_day_id, end_day_id, + check_in, check_in_end, check_out, confirmation, notes, created_at + FROM day_accommodations + `); + + this.addSql(`DROP TABLE day_accommodations`); + + this.addSql(`ALTER TABLE day_accommodations_new RENAME TO day_accommodations`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts new file mode 100644 index 000000000..add7bd2cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 121 (`db/migrations.ts`). + * + * Migration: null out proxy image_url entries that have no backing disk cache. + * Migrations 107 and the migration below wrote /api/maps/place-photo//bytes + * into places.image_url without actually fetching/caching the photo bytes. The + * photoService short-circuits on that prefix and hits /bytes directly → 404. + * Rows with a confirmed disk cache entry in google_place_photo_meta are left alone; + * only stale proxy URLs (never actually fetched) are cleared so the normal + * fetch-and-cache flow can repopulate them. + */ +export class Migration20200101020100_null_out_proxy_image_url_entries_that extends Migration { + override name = 'Migration20200101020100_null_out_proxy_image_url_entries_that'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, updated_at = CURRENT_TIMESTAMP + WHERE image_url LIKE '/api/maps/place-photo/%/bytes' + AND google_place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM google_place_photo_meta + WHERE place_id = places.google_place_id + AND error_at IS NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts new file mode 100644 index 000000000..b9a8863bd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 122 (`db/migrations.ts`). + * + * Migration: clear legacy Google photo URLs missed by Migration 107. + * Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use + * /place-photos/ or /places/ paths and were skipped. NULL those stale URLs + * so the normal fetch-and-cache flow repopulates image_url with a real proxy URL. + */ +export class Migration20200101020200_clear_legacy_google_photo_urls_missed_by extends Migration { + override name = 'Migration20200101020200_clear_legacy_google_photo_urls_missed_by'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE image_url IS NOT NULL + AND image_url != '' + AND image_url NOT LIKE '/api/maps/place-photo/%' + AND ( + image_url LIKE 'http://%googleusercontent.com/%' + OR image_url LIKE 'https://%googleusercontent.com/%' + OR image_url LIKE 'http://%places.googleapis.com/%' + OR image_url LIKE 'https://%places.googleapis.com/%' + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts new file mode 100644 index 000000000..cae9d29ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts @@ -0,0 +1,104 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 123 (`db/migrations.ts`). + * + * Migration 121: Journey gallery refactor — decouple photo ownership from + * entries. `journey_photos` becomes a per-journey gallery (one row per unique + * photo per journey). A new junction table `journey_entry_photos` links gallery + * photos to the entries that reference them, so the same photo can appear in + * several entries without being duplicated. Synthetic wrapper entries + * ('Gallery', '[Trip Photos]') created by the old model are removed — the + * gallery table replaces them. + */ +export class Migration20200101020300_journey_gallery_refactor extends Migration { + override name = 'Migration20200101020300_journey_gallery_refactor'; + + override async up(): Promise { + const hasOld = await tableExists(this, 'journey_photos'); + const hasBackup = await tableExists(this, 'journey_photos_old'); + if (hasOld && !hasBackup) { + await this.execute(`ALTER TABLE journey_photos RENAME TO journey_photos_old`); + } + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + shared INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + provider TEXT, + asset_id TEXT, + owner_id INTEGER, + created_at INTEGER NOT NULL, + UNIQUE(journey_id, photo_id) + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_entry_photos ( + entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE, + journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY(entry_id, journey_photo_id) + ) + `); + + if (hasOld || hasBackup) { + // Backfill the gallery: deduplicate by (journey_id, photo_id), keeping the + // earliest row (MIN(id) = earliest created_at on AUTOINCREMENT). + await this.execute(` + INSERT OR IGNORE INTO journey_photos + (journey_id, photo_id, caption, shared, sort_order, created_at) + SELECT + je.journey_id, + jpo.photo_id, + jpo.caption, + jpo.shared, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + WHERE jpo.id IN ( + SELECT MIN(jpo2.id) + FROM journey_photos_old jpo2 + JOIN journey_entries je2 ON je2.id = jpo2.entry_id + GROUP BY je2.journey_id, jpo2.photo_id + ) + `); + + // Backfill the junction: one row per (entry_id, photo_id), resolved to the + // new gallery ids. + await this.execute(` + INSERT OR IGNORE INTO journey_entry_photos + (entry_id, journey_photo_id, sort_order, created_at) + SELECT + jpo.entry_id, + jp.id, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + JOIN journey_photos jp + ON jp.journey_id = je.journey_id + AND jp.photo_id = jpo.photo_id + `); + + await this.execute(`DROP TABLE journey_photos_old`); + } + + // Remove the synthetic wrapper entries the gallery model replaces. The + // ON DELETE CASCADE on journey_entry_photos cleans up the junction rows. + await this.execute(`DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)`); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts new file mode 100644 index 000000000..765af9be5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 124 (`db/migrations.ts`). + * + * Migration 122: Correct stale day_id / end_day_id on non-transport + * reservations. Migration 110 only backfilled transport types; tours, + * restaurants, events and "other" bookings kept a stale day_id from + * older code paths that often defaulted to the first day of the trip. + * Starting with v3.0.0 the planner renders reservations by day_id + * instead of reservation_time, so those stale rows show up on the + * wrong day. This migration nulls out day_id / end_day_id values that + * don't match the reservation's time and then backfills them from + * reservation_time / reservation_end_time. + */ +export class Migration20200101020400_correct_stale_day_id_end_day_id extends Migration { + override name = 'Migration20200101020400_correct_stale_day_id_end_day_id'; + + override up(): void { + this.addSql(` + UPDATE reservations + SET day_id = NULL + WHERE reservation_time IS NOT NULL + AND day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.day_id + AND d.date = substr(reservations.reservation_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = NULL + WHERE reservation_end_time IS NOT NULL + AND end_day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.end_day_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) + != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts new file mode 100644 index 000000000..d6f2cebc5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 125 (`db/migrations.ts`). + * + * #846: make sort_order authoritative within a day. The previous ORDER BY put + * entry_time before sort_order, silently ignoring reorder clicks when two + * same-date entries had different times. The backfill renumbers using the old + * effective key (entry_time ASC, id ASC) so existing journeys keep their + * current visual order. + */ +export class Migration20200101020500_846_make_sort_order_authoritative_within_a extends Migration { + override name = 'Migration20200101020500_846_make_sort_order_authoritative_within_a'; + + override up(): void { + this.addSql(` + WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY journey_id, entry_date + ORDER BY entry_time ASC, id ASC + ) - 1 AS rn + FROM journey_entries + ) + UPDATE journey_entries + SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) + `); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_order ON journey_entries(journey_id, entry_date, sort_order)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts new file mode 100644 index 000000000..2c52ac6fd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 126 (`db/migrations.ts`). + * + * Swap inverted start_day_id/end_day_id pairs in day_accommodations caused + * by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs + * instead of positional order on trips with non-monotonic day ID layouts. + */ +export class Migration20200101020600_swap_inverted_start_day_id_end_day extends Migration { + override name = 'Migration20200101020600_swap_inverted_start_day_id_end_day'; + + override up(): void { + this.addSql(` + UPDATE day_accommodations + SET start_day_id = end_day_id, end_day_id = start_day_id + WHERE (SELECT day_number FROM days WHERE id = start_day_id) + > (SELECT day_number FROM days WHERE id = end_day_id) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts new file mode 100644 index 000000000..e69030b32 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts @@ -0,0 +1,21 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 127 (`db/migrations.ts`). + * + * Prepare migration to nest + typeorm. + */ +export class Migration20200101020700_prepare_migration_to_nest_typeorm extends Migration { + override name = 'Migration20200101020700_prepare_migration_to_nest_typeorm'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + await this.execute(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344')`); + await this.execute(`INSERT INTO app_settings (key, value) VALUES ('app_version', ?)`, [ + readEnv().app.appVersion || '3.0.14', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts new file mode 100644 index 000000000..401ec39d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts @@ -0,0 +1,91 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A user row with whitespace around its username or email. */ +interface DirtyRow { + id: number; + username?: string; + email?: string; +} + +/** + * Legacy migration step 128 (`db/migrations.ts`). + * + * Trim leading/trailing whitespace from stored usernames and emails. + * + * A trimmed value can collide with an existing account, and the unique indexes + * would reject the UPDATE. Rather than fail the boot, the colliding row is + * suffixed with `__migrated_` and a flag is written to `app_settings` so the + * admin UI can surface that a manual review is needed. A renamed email means + * that user cannot sign in with it until an operator fixes it — which is the + * behaviour the original chose, deliberately, over refusing to start. + */ +export class Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames extends Migration { + override name = 'Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames'; + + override async up(): Promise { + let hadCollision = false; + + const dirtyUsernames = (await this.execute( + `SELECT id, username FROM users WHERE username != TRIM(username)`, + )) as DirtyRow[]; + + for (const row of dirtyUsernames) { + const trimmed = row.username!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; + if (collision) { + hadCollision = true; + console.warn( + `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + + `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `Manual review required.`, + ); + } else { + console.warn( + `[migration] Trimmed username for user id=${row.id}: ` + `${JSON.stringify(row.username)} → "${final}"`, + ); + } + await this.execute(`UPDATE users SET username = ? WHERE id = ?`, [final, row.id]); + } + + const dirtyEmails = (await this.execute(`SELECT id, email FROM users WHERE email != TRIM(email)`)) as DirtyRow[]; + + for (const row of dirtyEmails) { + const trimmed = row.email!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + let final = trimmed; + if (collision) { + hadCollision = true; + const at = trimmed.lastIndexOf('@'); + final = + at > 0 ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` : `${trimmed}__migrated_${row.id}`; + console.warn( + `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + + `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `User cannot sign in with this email until manually corrected.`, + ); + } else { + console.warn(`[migration] Trimmed email for user id=${row.id}: ` + `${JSON.stringify(row.email)} → "${final}"`); + } + await this.execute(`UPDATE users SET email = ? WHERE id = ?`, [final, row.id]); + } + + if (hadCollision) { + await this.execute( + `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')`, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts new file mode 100644 index 000000000..eb23cdff9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts @@ -0,0 +1,24 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 129 (`db/migrations.ts`). + * + * Give `schema_version` a primary key. The original table was a single bare + * `version` column. + */ +export class Migration20200101020900_create_schema_version_new extends Migration { + override name = 'Migration20200101020900_create_schema_version_new'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL)`, + ); + await this.execute(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); + await this.execute(`DROP TABLE schema_version`); + await this.execute(`ALTER TABLE schema_version_new RENAME TO schema_version`); + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'app_version'`, [ + readEnv().app.appVersion || '3.0.15', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101021000_oauth_2.ts b/server/src/db/migrations/Migration20200101021000_oauth_2.ts new file mode 100644 index 000000000..a208b1ce5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021000_oauth_2.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 130 (`db/migrations.ts`). + * + * Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential + * clients to skip the browser consent flow entirely and obtain tokens directly + * via client_id + client_secret. Flag is immutable after creation so existing + * authorization-code clients are not silently upgraded. + */ +export class Migration20200101021000_oauth_2 extends Migration { + override name = 'Migration20200101021000_oauth_2'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_clients', + 'allows_client_credentials', + `allows_client_credentials INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts new file mode 100644 index 000000000..7689816ba --- /dev/null +++ b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts @@ -0,0 +1,38 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** [minLng, minLat, maxLng, maxLat] per territory. */ +const ENCLAVE_BOXES: [number, number, number, number][] = [ + [113.83, 22.15, 114.43, 22.56], // HK + [113.53, 22.1, 113.6, 22.21], // MO + [12.4, 43.89, 12.52, 43.99], // SM + [12.44, 41.9, 12.46, 41.91], // VA + [7.4, 43.72, 7.44, 43.75], // MC + [9.47, 47.05, 9.64, 47.27], // LI + [-5.36, 36.11, -5.33, 36.16], // GI + [-67.3, 17.88, -65.22, 18.53], // PR +]; + +/** + * Legacy migration step 131 (`db/migrations.ts`). + * + * Drop stale atlas cache rows for territories that used to resolve to their + * surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy, + * etc.) before their own bounding boxes existed. The next atlas stats request + * re-resolves any place inside these boxes with the corrected country code. + */ +export class Migration20200101021100_drop_stale_atlas_cache_rows_for_territories extends Migration { + override name = 'Migration20200101021100_drop_stale_atlas_cache_rows_for_territories'; + + override async up(): Promise { + for (const [minLng, minLat, maxLng, maxLat] of ENCLAVE_BOXES) { + await execUnlessTableMissing( + this, + `DELETE FROM place_regions WHERE place_id IN ( + SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? + )`, + [minLat, maxLat, minLng, maxLng], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101021200_costs_rework.ts b/server/src/db/migrations/Migration20200101021200_costs_rework.ts new file mode 100644 index 000000000..b602da336 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021200_costs_rework.ts @@ -0,0 +1,65 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 132 (`db/migrations.ts`). + * + * Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively + * and without touching existing rows: + * - per-expense currency + exchange_rate, so an expense can be entered in a + * foreign currency and converted to the trip base currency (NULL currency = + * base currency; rate 1.0). Closes the multi-currency request (#551). + * - budget_item_payers: several people can each have paid part of one expense + * (amounts in the expense currency), replacing the single paid_by_user_id. + * - budget_settlements: persisted "X paid Y" transfers so the settle-up history + * (with undo) is shared across all trip members. + * + * The equal-split participants stay in budget_item_members. The single legacy + * payer is backfilled into budget_item_payers as one payer covering the total. + */ +export class Migration20200101021200_costs_rework extends Migration { + override name = 'Migration20200101021200_costs_rework'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'currency', `currency TEXT`); + await addColumnIfMissing(this, 'budget_items', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_item_payers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_settlements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_user_id INTEGER REFERENCES users(id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)`); + + // Backfill the legacy single payer: that person paid the full total of the + // expense, in the (base) currency the existing amount was already stored in. + // `paid_by_user_id` does not exist on every database — skip where it is absent. + try { + await this.execute(` + INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) + SELECT id, paid_by_user_id, total_price + FROM budget_items + WHERE paid_by_user_id IS NOT NULL + `); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such column')) throw err; + } + } +} diff --git a/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts new file mode 100644 index 000000000..74bba8b6e --- /dev/null +++ b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 133 (`db/migrations.ts`). + * + * Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This is + * a display rename only — the addon id, tables, permissions and MCP tools all + * stay 'budget'. Scoped to the default name so a customised one is kept. + */ +export class Migration20200101021300_rename_the_budget_planner_addon_to_costs extends Migration { + override name = 'Migration20200101021300_rename_the_budget_planner_addon_to_costs'; + + override up(): void { + this.addSql( + `UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts new file mode 100644 index 000000000..69cc73d35 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts @@ -0,0 +1,46 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 134 (`db/migrations.ts`). + * + * WebAuthn / passkey support: per-user credentials + single-use login + * challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are + * untouched; both tables also live in schema.ts for fresh installs. + */ +export class Migration20200101021400_webauthn_passkey_support_per_user_credentials_single extends Migration { + override name = 'Migration20200101021400_webauthn_passkey_support_per_user_credentials_single'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts new file mode 100644 index 000000000..94292840f --- /dev/null +++ b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts @@ -0,0 +1,131 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +/** A manually-marked sub-national region carrying a possibly-stale code. */ +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Curated crosswalk for regions absorbed into a *renamed* successor (the + * name-match step cannot catch these, because the name changed). Norway's + * 2018/2020 reforms; extend as the pinned geoBoundaries dataset gains further + * reforms. + */ +const MERGE_CROSSWALK: Record = { + 'NO-04': 'NO-34', + 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet + 'NO-12': 'NO-46', + 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland + 'NO-09': 'NO-42', + 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder + 'NO-01': 'NO-30', + 'NO-02': 'NO-30', + 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken + 'NO-07': 'NO-38', + 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark + 'NO-19': 'NO-54', + 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark + 'NO-16': 'NO-50', + 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag +}; + +/** + * Legacy migration step 135 (`db/migrations.ts`). + * + * Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national + * regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; + * some no longer match any polygon in the new bundle and would stop + * highlighting. Reconcile every row against the ACTUAL shipped admin-1 bundle so + * this covers *all* countries, not just one hand-listed reform: + * 1. code still present in the new bundle → leave it (already correct); + * 2. else a region in the same country shares → adopt that region's code+name + * the stored region_name (case-insensitive) (handles code re-spellings, + * e.g. ES-AN → ES_AND); + * 3. else a curated merge crosswalk maps it → adopt the merged region; + * 4. else → leave as-is (cannot be resolved; the client's name fallback may + * still highlight it, and nothing is destroyed). + * + * Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold + * only ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` + * is free text we must not auto-rewrite, and `place_regions` is a re-derivable + * Nominatim cache. + */ +export class Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries extends Migration { + override name = 'Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions`, + )) as VisitedRegion[]; + if (rows.length === 0) return; // nothing marked → skip the bundle read entirely + + // Index the shipped admin-1 bundle: valid codes, name→code per country, + // code→name. `__dirname` resolves ../../../assets under both dist + // (dist/db/migrations) and ts-node/vitest (src/db/migrations). + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + features = []; // bundle missing → degrade to the curated crosswalk below + } + + const validCodes = new Set(); + const nameToCode = new Map(); // `${A2}|${nameLower}` → code + const codeToName = new Map(); + for (const f of features) { + const a2 = (f.properties?.iso_a2 || '').toUpperCase(); + const code = f.properties?.iso_3166_2 || ''; + const name = f.properties?.name || ''; + if (!code) continue; + validCodes.add(code); + if (!codeToName.has(code)) codeToName.set(code, name); + if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code); + } + + const resolve = (row: VisitedRegion): string | null => { + if (validCodes.has(row.region_code)) return null; // already valid + const a2 = (row.country_code || '').toUpperCase(); + const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`); + if (byName) return byName; + const merged = MERGE_CROSSWALK[row.region_code]; + // Only trust the crosswalk target if it actually exists in the bundle (or + // the bundle was unreadable, in which case apply the curated map blindly). + if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged; + return null; + }; + + for (const row of rows) { + const newCode = resolve(row); + if (!newCode || newCode === row.region_code) continue; + const newName = codeToName.get(newCode) || row.region_name; + + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?`, [ + newCode, + newName, + row.id, + ]); + + // UNIQUE(user_id, region_code): if the user already had the target code the + // UPDATE was IGNORED and this row still carries the old code → drop the + // duplicate. + const after = (await this.execute(`SELECT region_code FROM visited_regions WHERE id = ?`, [row.id])) as { + region_code: string; + }[]; + if (after[0]?.region_code === row.region_code) { + await this.execute(`DELETE FROM visited_regions WHERE id = ?`, [row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101021600_step_136.ts b/server/src/db/migrations/Migration20200101021600_step_136.ts new file mode 100644 index 000000000..3302dcead --- /dev/null +++ b/server/src/db/migrations/Migration20200101021600_step_136.ts @@ -0,0 +1,21 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 136 (`db/migrations.ts`). + * + * AirTrail integration addon — disabled by default (opt-in). The per-user + * connection lives in Settings → Integrations; this row is only the admin-level + * global toggle. + */ +export class Migration20200101021600_step_136 extends Migration { + override name = 'Migration20200101021600_step_136'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts new file mode 100644 index 000000000..469d3a34d --- /dev/null +++ b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 137 (`db/migrations.ts`). + */ +export class Migration20200101021700_users_add_airtrail_url extends Migration { + override name = 'Migration20200101021700_users_add_airtrail_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_url', `airtrail_url TEXT`); + + await addColumnIfMissing(this, 'users', 'airtrail_api_key', `airtrail_api_key TEXT`); + + await addColumnIfMissing( + this, + 'users', + 'airtrail_allow_insecure_tls', + `airtrail_allow_insecure_tls INTEGER DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts new file mode 100644 index 000000000..4b9c6b354 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 138 (`db/migrations.ts`). + */ +export class Migration20200101021800_reservations_add_external_source extends Migration { + override name = 'Migration20200101021800_reservations_add_external_source'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'external_source', `external_source TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_id', `external_id TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_owner_user_id', `external_owner_user_id INTEGER`); + + await addColumnIfMissing(this, 'reservations', 'external_synced_at', `external_synced_at TEXT`); + + await addColumnIfMissing(this, 'reservations', 'sync_enabled', `sync_enabled INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'reservations', 'external_hash', `external_hash TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts new file mode 100644 index 000000000..71dc7b906 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 139 (`db/migrations.ts`). + */ +export class Migration20200101021900_users_add_airtrail_write_enabled extends Migration { + override name = 'Migration20200101021900_users_add_airtrail_write_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_write_enabled', `airtrail_write_enabled INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts new file mode 100644 index 000000000..81d316e58 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 140 (`db/migrations.ts`). + * + * Store Google Maps feature IDs separately from real Google Places API IDs. + */ +export class Migration20200101022000_store_google_maps_feature_ids_separately_from extends Migration { + override name = 'Migration20200101022000_store_google_maps_feature_ids_separately_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'google_ftid', `google_ftid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts new file mode 100644 index 000000000..015d12ec6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 141 (`db/migrations.ts`). + * + * Remember the app version a notice was dismissed at, so per-version recurring + * notices (e.g. the thank-you) re-appear on the next install/upgrade. + */ +export class Migration20200101022100_remember_the_app_version_a_notice_was extends Migration { + override name = 'Migration20200101022100_remember_the_app_version_a_notice_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'user_notice_dismissals', 'dismissed_app_version', `dismissed_app_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts new file mode 100644 index 000000000..bb48a476d --- /dev/null +++ b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 142 (`db/migrations.ts`). + */ +export class Migration20200101022200_budget_item_members_add_amount extends Migration { + override name = 'Migration20200101022200_budget_item_members_add_amount'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_item_members', 'amount', `amount REAL`); + } +} diff --git a/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts new file mode 100644 index 000000000..9526d07ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 143 (`db/migrations.ts`). + * + * Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds + */ +export class Migration20200101022300_calendar_feed_tokens extends Migration { + override name = 'Migration20200101022300_calendar_feed_tokens'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'feed_token', `feed_token TEXT`); + + await addColumnIfMissing(this, 'users', 'feed_token', `feed_token TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL`, + ); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts new file mode 100644 index 000000000..61c28d653 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 144 (`db/migrations.ts`). + * + * Optimistic-concurrency token for offline conflict detection (#1135). + * packing_items had only created_at, so an offline edit could not be checked + * against a concurrent server change. SQLite forbids a non-constant DEFAULT on + * ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows + * set it explicitly (packingService). Additive: a request without the + * X-Base-Updated-At header keeps the old last-write-wins behaviour. + */ +export class Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection extends Migration { + override name = 'Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'updated_at', `updated_at DATETIME`); + + await this.execute( + `UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022500_video_support.ts b/server/src/db/migrations/Migration20200101022500_video_support.ts new file mode 100644 index 000000000..23ee89f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022500_video_support.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 145 (`db/migrations.ts`). + * + * Video support (#823): the trek_photos registry held only images. media_type + * discriminates image vs video so the gallery, lightbox and provider proxy can + * branch; duration_ms is optional metadata for the player. Additive — existing + * rows default to 'image'. + */ +export class Migration20200101022500_video_support extends Migration { + override name = 'Migration20200101022500_video_support'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'media_type', `media_type TEXT NOT NULL DEFAULT 'image'`); + await addColumnIfMissing(this, 'trek_photos', 'duration_ms', `duration_ms INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts new file mode 100644 index 000000000..90fb684a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 146 (`db/migrations.ts`). + * + * Dedicated booking URL (#935) — users previously stuffed links into notes. + * Additive nullable TEXT; existing rows default to NULL. + */ +export class Migration20200101022600_dedicated_booking_url extends Migration { + override name = 'Migration20200101022600_dedicated_booking_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'url', `url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022700_private_packing_items.ts b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts new file mode 100644 index 000000000..e7d879fe9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 147 (`db/migrations.ts`). + * + * Private packing items (#858): an item can be hidden from other trip members. + * `is_private` toggles the visibility; `owner_id` records who it belongs to so + * the listing can show it only to them. `owner_id` is NULL on legacy rows + * (shared). + */ +export class Migration20200101022700_private_packing_items extends Migration { + override name = 'Migration20200101022700_private_packing_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'is_private', `is_private INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing( + this, + 'packing_items', + 'owner_id', + `owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022800_guest_members.ts b/server/src/db/migrations/Migration20200101022800_guest_members.ts new file mode 100644 index 000000000..d491e5f14 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022800_guest_members.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 148 (`db/migrations.ts`). + * + * Guest members (#1362): people added to a trip without an account. A guest is a + * users row flagged is_guest=1 (no usable credentials) joined into trip_members, + * so it's assignable everywhere a member is — but must never authenticate or show + * up in the global user directory. The flag is the discriminator for those guards. + */ +export class Migration20200101022800_guest_members extends Migration { + override name = 'Migration20200101022800_guest_members'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'is_guest', `is_guest INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts new file mode 100644 index 000000000..1a5aa389f --- /dev/null +++ b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 149 (`db/migrations.ts`). + * + * Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0, + * every existing item — non-breaking), Personal (is_private=1, owner only) or + * Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer". + * Contributors are extra people who said "I can bring that too" on a Common item + * (status 'pending' until the owner accepts). + */ +export class Migration20200101022900_three_tier_packing_sharing extends Migration { + override name = 'Migration20200101022900_three_tier_packing_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_recipients ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_contributors ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'accepted', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023000_collections_addon.ts b/server/src/db/migrations/Migration20200101023000_collections_addon.ts new file mode 100644 index 000000000..7f7e89a10 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023000_collections_addon.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 150 (`db/migrations.ts`). + * + * Collections addon — personal place library (#1081). Multi-list + per-place + * status (idea/want/visited) + fusion sharing. + */ +export class Migration20200101023000_collections_addon extends Migration { + override name = 'Migration20200101023000_collections_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts new file mode 100644 index 000000000..57e68b255 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 151 (`db/migrations.ts`). + * + * Migration 151: user-added links on collections + saved places (JSON text) + */ +export class Migration20200101023100_user_added_links_on_collections_saved_places extends Migration { + override name = 'Migration20200101023100_user_added_links_on_collections_saved_places'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collections', 'links', `links TEXT`); + + await addColumnIfMissing(this, 'collection_places', 'links', `links TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts new file mode 100644 index 000000000..113fa420a --- /dev/null +++ b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 152 (`db/migrations.ts`). + * + * Migration 152: per-member permission role on a shared list. Existing + * accepted members default to 'editor' so nothing regresses. + */ +export class Migration20200101023200_per_member_permission_role_on_a_shared extends Migration { + override name = 'Migration20200101023200_per_member_permission_role_on_a_shared'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collection_members', 'role', `role TEXT NOT NULL DEFAULT 'editor'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts new file mode 100644 index 000000000..b046845a2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 153 (`db/migrations.ts`). + * + * Migration 153: per-trip invite links (#1143). One rotating token per trip; + * a logged-in existing user who opens the link joins the trip as a member. + * Deleting the trip drops the token (CASCADE); the creator is nulled if their + * account is removed so the link keeps working. + */ +export class Migration20200101023300_per_trip_invite_links extends Migration { + override name = 'Migration20200101023300_per_trip_invite_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + expires_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts new file mode 100644 index 000000000..5bbd83bcb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 154 (`db/migrations.ts`). + * + * Migration 154: optional trip binding on an admin invite link (#1402). A user + * who REGISTERS via the link is auto-added to the trip. Nullable for backward + * compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. + */ +export class Migration20200101023400_optional_trip_binding_on_an_admin_invite extends Migration { + override name = 'Migration20200101023400_optional_trip_binding_on_an_admin_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'invite_tokens', + 'trip_id', + `trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts new file mode 100644 index 000000000..4e4e0ccbb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts @@ -0,0 +1,83 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 155 (`db/migrations.ts`). + * + * Migration 155: plugin system scaffold (#plugins). A plugin is a row here; + * its code lives on the /plugins volume and (once the runtime lands) runs in + * an isolated child process. This migration only lays down the registry + * tables — nothing executes yet. Own data lives in a per-plugin sqlite file + * under /plugins-data, never in these tables. + */ +export class Migration20200101023500_plugin_system_scaffold extends Migration { + override name = 'Migration20200101023500_plugin_system_scaffold'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'integration', + icon TEXT DEFAULT 'Blocks', + version TEXT, + api_version INTEGER DEFAULT 1, + min_trek_version TEXT, + permissions TEXT DEFAULT '[]', + granted_permissions TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'inactive', + config TEXT DEFAULT '{}', + source_repo TEXT, + source_commit TEXT, + sha256 TEXT, + crash_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + reviewed_at TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_meta_migrations ( + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, migration_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + ts DATETIME DEFAULT CURRENT_TIMESTAMP, + level TEXT NOT NULL DEFAULT 'error', + message TEXT, + stack TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_settings_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + field_key TEXT NOT NULL, + label TEXT, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER NOT NULL DEFAULT 0, + secret INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'instance', + options TEXT, + oauth_config TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (plugin_id, field_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts new file mode 100644 index 000000000..02656b020 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 156 (`db/migrations.ts`). + * + * Migration 156: separate the admin's ON/OFF intent (`enabled`) from the + * runtime health (`status`). A crash used to flip status to 'error', which + * erased the "keep it on" intent, so the plugin never rebooted after a deploy. + * Boot now retries every enabled plugin regardless of last status. + */ +export class Migration20200101023600_separate_the_admin_s_on_off_intent extends Migration { + override name = 'Migration20200101023600_separate_the_admin_s_on_off_intent'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'enabled', `enabled INTEGER NOT NULL DEFAULT 0`); + + await execBestEffort(this, `UPDATE plugins SET enabled = 1 WHERE status != 'inactive'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts new file mode 100644 index 000000000..14f67399d --- /dev/null +++ b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 157 (`db/migrations.ts`). + * + * Migration 157: plugin capabilities (from trek-plugin.json) — the client + * needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay + * on the boarding-pass bar instead of the dashboard sidebar). + */ +export class Migration20200101023700_plugin_capabilities extends Migration { + override name = 'Migration20200101023700_plugin_capabilities'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'capabilities', `capabilities TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts new file mode 100644 index 000000000..7d2619a8c --- /dev/null +++ b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 158 (`db/migrations.ts`). + * + * Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4). + * Set on first install of a signed plugin; a later install whose registry key + * differs is a hard stop (author change / key rotation / attack) unless an + * admin re-trusts. NULL for unsigned plugins (signing is opt-in). + */ +export class Migration20200101023800_tofu_pin_for_a_plugin_s_author extends Migration { + override name = 'Migration20200101023800_tofu_pin_for_a_plugin_s_author'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'author_pubkey', `author_pubkey TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts new file mode 100644 index 000000000..0323cad41 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts @@ -0,0 +1,39 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 159 (`db/migrations.ts`). + * + * Migration 159: hash-chained capability audit log (#plugins, L1 hardening). + * Every host-mediated capability call the plugin makes is recorded at the RPC + * boundary (where the plugin provably can't reach) with the host-bound acting + * user and a per-plugin hash chain, so wide data grants stay attributable + + * tamper-evident + user-visible. + */ +export class Migration20200101023900_hash_chained_capability_audit_log extends Migration { + override name = 'Migration20200101023900_hash_chained_capability_audit_log'; + + override async up(): Promise { + await execBestEffort( + this, + ` + CREATE TABLE IF NOT EXISTS plugin_capability_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + acting_user_id INTEGER, + method TEXT NOT NULL, + resource TEXT, + code TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + prev_hash TEXT, + hash TEXT NOT NULL + ) + `, + ); + + await execBestEffort( + this, + `CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts new file mode 100644 index 000000000..ca8c9ecfe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 160 (`db/migrations.ts`). + * + * Migration 160: per-collection custom labels (#collections). Each list owns + * its own label set (unlike the instance-wide `tags` table), and a place can + * carry several labels. Used for grouping + filtering places within a list. + */ +export class Migration20200101024000_per_collection_custom_labels extends Migration { + override name = 'Migration20200101024000_per_collection_custom_labels'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts new file mode 100644 index 000000000..d881a20fe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts @@ -0,0 +1,34 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 161 (`db/migrations.ts`). + * + * Migration 161: plugin-owned metadata on core entities (#1429). A namespaced + * key/value store so a plugin can attach data to a trip/place/day WITHOUT + * forking the core schema. One row per (plugin, entity, key); a plugin only + * ever sees its own rows. entity_type is polymorphic (no cross-table FK), so + * rows are purged by plugin_id on uninstall; entity deletes leave harmless + * orphans that the plugin's own reads never surface. + */ +export class Migration20200101024100_plugin_owned_metadata_on_core_entities extends Migration { + override name = 'Migration20200101024100_plugin_owned_metadata_on_core_entities'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_entity_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, entity_type, entity_id, key) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts new file mode 100644 index 000000000..8dc5be0b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 162 (`db/migrations.ts`). + * + * Freeze the FX rate on settle-up transfers too (#1445). budget_settlements + * stored only a bare `amount` in whatever display currency the payer was + * viewing, so a later live-rate drift re-opened an already-settled position + * with a few-cent residual. Capture the display `currency` and the rate frozen + * at settle time (units of that currency per 1 trip currency), mirroring the + * budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on + * live rates until re-edited. + */ +export class Migration20200101024200_freeze_the_fx_rate_on_settle_up extends Migration { + override name = 'Migration20200101024200_freeze_the_fx_rate_on_settle_up'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'currency', `currency TEXT`); + + await addColumnIfMissing(this, 'budget_settlements', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts new file mode 100644 index 000000000..f8bc726b7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 163 (`db/migrations.ts`). + * + * #1446: guests are per-trip people, but their display name lived in the globally + * UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to + * "Jake 2". Add a non-unique display_name; new guests store the human name here and + * get a uuid-based username that is never shown (the member views COALESCE to it). + */ +export class Migration20200101024300_1446_guests_are_per_trip_people_but extends Migration { + override name = 'Migration20200101024300_1446_guests_are_per_trip_people_but'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'display_name', `display_name TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts new file mode 100644 index 000000000..8d1e48208 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 164 (`db/migrations.ts`). + * + * Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare + * `requiredAddons` (addon ids that must be enabled to activate) and + * `pluginDependencies` ({id, version-range} of other plugins that must be + * installed + satisfied). Stored as one JSON blob and populated by the + * discovery upsert on every install path. Legacy rows default to '{}' (no deps). + */ +export class Migration20200101024400_plugin_dependencies extends Migration { + override name = 'Migration20200101024400_plugin_dependencies'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'dependencies', `dependencies TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts new file mode 100644 index 000000000..c180d68a7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 165 (`db/migrations.ts`). + * + * Per-user plugin settings (#plugins). A plugin can declare `scope:'user'` + * settings fields (e.g. an API key or a personal preference); each USER stores + * their own values here, separate from the admin-owned instance `plugins.config`. + * Secrets are encrypted with the same apiKeyCrypto as instance secrets and are + * never echoed back to the client (masked). Runtime reads the acting user's row. + */ +export class Migration20200101024500_per_user_plugin_settings extends Migration { + override name = 'Migration20200101024500_per_user_plugin_settings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_config ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts new file mode 100644 index 000000000..c42b527ae --- /dev/null +++ b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 166 (`db/migrations.ts`). + * + * Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a + * third-party service; the HOST runs authorize->callback->token->refresh with + * PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens + * are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived. + */ +export class Migration20200101024600_host_brokered_outbound_oauth extends Migration { + override name = 'Migration20200101024600_host_brokered_outbound_oauth'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_tokens ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + scope TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_state ( + state TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + verifier TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts new file mode 100644 index 000000000..2470eff02 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 167 (`db/migrations.ts`). + * + * Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run` + * grant can schedule a userless callback to fire at a future time (once, or + * recurring), surviving server restarts because the entry lives here. Same risk + * class as cron jobs (no user, no trip reads, own db + declared egress only) — + * so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes + * scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall. + */ +export class Migration20200101024700_persistent_plugin_scheduler extends Migration { + override name = 'Migration20200101024700_persistent_plugin_scheduler'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + name TEXT NOT NULL, + due_at INTEGER NOT NULL, + payload TEXT NOT NULL DEFAULT 'null', + every_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, name) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts new file mode 100644 index 000000000..7dafe5607 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 168 (`db/migrations.ts`). + * + * Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every + * installed plugin holding `hook:user-data` gets a pending row here so its own + * deleteUserData handler runs even if the plugin was offline at delete time — + * erasure must not be lost across a restart, so it is persisted (unlike the + * best-effort event buffer). The row is removed once the plugin acknowledges, + * and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id) + * makes re-enqueue idempotent. + */ +export class Migration20200101024800_durable_gdpr_erasure_queue extends Migration { + override name = 'Migration20200101024800_durable_gdpr_erasure_queue'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts new file mode 100644 index 000000000..0121713cc --- /dev/null +++ b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 169 (`db/migrations.ts`). + * + * Tombstones for Atlas countries the user has explicitly removed (#1490). + * Atlas derives visited countries from trip places and transport endpoints on every + * request, so those countries have no row to delete — "Remove" deleted from + * visited_countries (which never had the row), the client hid it optimistically, and + * the next getStats re-derived it. Recording the removal here lets getStats suppress + * a derived country. Re-marking a country deletes its tombstone. + */ +export class Migration20200101024900_tombstones_for_atlas_countries_the_user_has extends Migration { + override name = 'Migration20200101024900_tombstones_for_atlas_countries_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, country_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts new file mode 100644 index 000000000..85ce0d166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts @@ -0,0 +1,37 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 170 (`db/migrations.ts`). + * + * Operator-supplied egress hosts for a plugin (#plugins). + * + * A plugin's egress allow-list is fixed in its manifest at publish time, but a + * plugin that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the + * operator's hostname — so a community plugin could serve nobody. These rows let + * the ADMIN add hosts post-install; the runtime unions them into the child's + * allow-list at spawn. Consent stays with the admin (never the end user), + * exactly as for manifest egress. + */ +export class Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin extends Migration { + override name = 'Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + host TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, host) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id)`); + + // Whether the plugin DECLARED that it needs operator-supplied hosts. Only + // such a plugin may have hosts added — an admin can never widen egress for a + // plugin that never asked for it, so install-time consent still bounds what + // is possible. + await addColumnIfMissing(this, 'plugins', 'operator_egress', `operator_egress INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts new file mode 100644 index 000000000..9f75f3554 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 171 (`db/migrations.ts`). + * + * Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). + * Descriptors only — the handler lives in the plugin's code and is invoked host-side + * with the CLICKING user bound, so it can read that user's own settings. + */ +export class Migration20200101025100_settings_page_action_buttons_a_plugin_contributes extends Migration { + override name = 'Migration20200101025100_settings_page_action_buttons_a_plugin_contributes'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_actions ( + plugin_id TEXT NOT NULL, + action_key TEXT NOT NULL, + label TEXT NOT NULL, + hint TEXT, + danger INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (plugin_id, action_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts new file mode 100644 index 000000000..cc60d7300 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 172 (`db/migrations.ts`). + * + * Why a plugin's update was REFUSED by the signature check (#plugins). A refused + * update leaves a working plugin pinned at its old version — previously the + * reason lived only in a transient toast, so the plugin quietly stopped updating + * and the admin had to re-attempt an update to rediscover why. Record it instead. + * + * `update_block_version` is the registry version that was refused: once the + * registry offers something NEWER, the block describes an artifact nobody is + * being offered anymore, so it reads as stale and the admin can just re-attempt + * (the next install re-verifies and either succeeds or re-blocks with fresh + * values). Deliberately no `status = 'error'` — the plugin still runs fine on + * its old code. + */ +export class Migration20200101025200_why_a_plugin_s_update_was_refused extends Migration { + override name = 'Migration20200101025200_why_a_plugin_s_update_was_refused'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_block_code', `update_block_code TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_detail', `update_block_detail TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_version', `update_block_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts new file mode 100644 index 000000000..62c4658cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 173 (`db/migrations.ts`). + * + * The semver RANGE of TREK versions a plugin declares it supports (its manifest's + * `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the + * lower bound, so it cannot express "stops working at 4.0" — which is precisely the + * case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin + * installed before this column existed has no range recorded, and the gate refuses to + * activate it rather than guessing (see TREK_VERSION_UNKNOWN). + */ +export class Migration20200101025300_the_semver_range_of_trek_versions_a extends Migration { + override name = 'Migration20200101025300_the_semver_range_of_trek_versions_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'trek_range', `trek_range TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts new file mode 100644 index 000000000..ec44ba57a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts @@ -0,0 +1,27 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 174 (`db/migrations.ts`). + * + * `place_regions` is a re-derivable Nominatim cache, only ever populated for a + * place ID that isn't already cached — so a wrong row, once written, was + * permanent. Region resolution now resolves a place's lat/lng directly against + * the bundled admin1 polygons (the same ones the client renders) instead of + * trusting Nominatim's address level, which could name a subdivision level the + * bundle doesn't carry (Barcelona's ES-B province vs the bundle's ES-CT + * autonomous community) and never highlight. That fix only helps places + * re-resolved after it, so clear the cache once and let every place re-resolve + * on the next Atlas load. The country_code stored alongside is cleared too, + * which also drops the old wrong-country rows a US-state-abbreviation address + * used to produce. + */ +export class Migration20200101025400_place_regions_is_a_re_derivable_nominatim extends Migration { + override name = 'Migration20200101025400_place_regions_is_a_re_derivable_nominatim'; + + override async up(): Promise { + // place_regions is created by an earlier migration; tolerate its absence on + // an unusual partial DB rather than aborting startup. + await execUnlessTableMissing(this, `DELETE FROM place_regions`); + } +} diff --git a/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts new file mode 100644 index 000000000..c9c61422a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 175 (`db/migrations.ts`). + * + * Tombstones for Atlas regions the user has explicitly removed — the region-level + * counterpart to hidden_countries above (#1490). A visited region is normally derived + * fresh from place_regions/visited_regions on every request, so "removing" it has + * nothing to delete; recording it here lets getVisitedRegions suppress a derived region + * the same way getStats already suppresses a derived country. Unlike the country-level + * tombstone (originally only reachable for a manually-marked or zero-count country), + * this also covers a region derived from real place data — e.g. one that ended up on + * the wrong side of a border-simplification gap and the user just wants gone. + */ +export class Migration20200101025500_tombstones_for_atlas_regions_the_user_has extends Migration { + override name = 'Migration20200101025500_tombstones_for_atlas_regions_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts new file mode 100644 index 000000000..a7f2964be --- /dev/null +++ b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 176 (`db/migrations.ts`). + * + * Half vacation days (#552): a vacay entry can now count as a full day (1) or + * a half day (0.5) toward the entitlement. Existing entries default to a full + * day, so the entitlement maths are unchanged for everyone already using vacay. + * Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. + */ +export class Migration20200101025600_half_vacation_days extends Migration { + override name = 'Migration20200101025600_half_vacation_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'fraction', `fraction REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts new file mode 100644 index 000000000..500a1abe8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 177 (`db/migrations.ts`). + * + * Read-only vacay calendar sharing (#444/#667): a user can let other users + * view their vacation calendar without fusing plans. owner_id is the sharing + * user, user_id the viewer; hidden lets the viewer hide the overlay without + * removing the share. Follows the person, not the plan, so it survives + * fusion and dissolution. + */ +export class Migration20200101025700_read_only_vacay_calendar_sharing extends Migration { + override name = 'Migration20200101025700_read_only_vacay_calendar_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + hidden INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts new file mode 100644 index 000000000..96c4ea004 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 178 (`db/migrations.ts`). + * + * Collaborative place ratings (#1435): every trip member can rate a trip + * place 1-5, every collection member a saved place; the displayed value is + * the average. One row per user and place, mirroring collab_poll_votes. + */ +export class Migration20200101025800_collaborative_place_ratings extends Migration { + override name = 'Migration20200101025800_collaborative_place_ratings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts new file mode 100644 index 000000000..0fcede2fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 179 (`db/migrations.ts`). + * + * Per-segment travel mode (#1281): the day-plan route can use a different + * transport mode for each leg. leg_transport_mode on an assignment is the mode + * of the leg LEAVING that stop (NULL = inherit the day default); days gains a + * persisted default_transport_mode so the whole-day choice survives a reload. + * Both nullable → existing itineraries keep today's single-mode behaviour. + */ +export class Migration20200101025900_per_segment_travel_mode extends Migration { + override name = 'Migration20200101025900_per_segment_travel_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'leg_transport_mode', `leg_transport_mode TEXT`); + + await addColumnIfMissing(this, 'days', 'default_transport_mode', `default_transport_mode TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts new file mode 100644 index 000000000..0590f238e --- /dev/null +++ b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 180 (`db/migrations.ts`). + * + * School holidays are a visual Vacay calendar layer. Keep them separate from + * public holidays so applyHolidayCalendars never removes vacation entries for + * school-break dates. + */ +export class Migration20200101030000_school_holidays_are_a_visual_vacay_calendar extends Migration { + override name = 'Migration20200101030000_school_holidays_are_a_visual_vacay_calendar'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'vacay_plans', + 'school_holidays_enabled', + `school_holidays_enabled INTEGER DEFAULT 0`, + ); + await addColumnIfMissing(this, 'vacay_holiday_calendars', 'type', `type TEXT NOT NULL DEFAULT 'public_holiday'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030100_1517.ts b/server/src/db/migrations/Migration20200101030100_1517.ts new file mode 100644 index 000000000..85a6466ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101030100_1517.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 181 (`db/migrations.ts`). + * + * #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). + */ +export class Migration20200101030100_1517 extends Migration { + override name = 'Migration20200101030100_1517'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS reservation_travelers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(reservation_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts new file mode 100644 index 000000000..ee464ac9f --- /dev/null +++ b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 182 (`db/migrations.ts`). + * + * Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward + * the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime + * offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5. + */ +export class Migration20200101030200_comp_flex_days extends Migration { + override name = 'Migration20200101030200_comp_flex_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'kind', `kind TEXT NOT NULL DEFAULT 'vacation'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts new file mode 100644 index 000000000..d0103718d --- /dev/null +++ b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 183 (`db/migrations.ts`). + * + * Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps + * the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed + * month/day; 'anniversary' starts on the month/day of the hire date. The year + * integer still names a period; the service resolves it to a [start,end) range. + */ +export class Migration20200101030300_configurable_vacation_year extends Migration { + override name = 'Migration20200101030300_configurable_vacation_year'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + year_type TEXT NOT NULL DEFAULT 'calendar', + year_start_month INTEGER NOT NULL DEFAULT 1, + year_start_day INTEGER NOT NULL DEFAULT 1, + hire_date TEXT + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts new file mode 100644 index 000000000..cf30cbdce --- /dev/null +++ b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 184 (`db/migrations.ts`). + * + * Manual GPX track colour (#776): imported tracks all render in the same blue + * because the importer never assigns a category, so several walks in the same + * area are indistinguishable. NULL keeps the old behaviour (category colour, + * then the #3b82f6 fallback) for every existing row. + */ +export class Migration20200101030400_manual_gpx_track_colour extends Migration { + override name = 'Migration20200101030400_manual_gpx_track_colour'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_color', `route_color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts new file mode 100644 index 000000000..95e3bd883 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +const PLACES_TOGGLES = ['places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled']; + +/** + * Legacy migration step 185 (`db/migrations.ts`). + * + * Backfill the three places feature toggles before their read flips from + * fail-open (`value !== 'false'`) to fail-closed (`value === 'true'`), so + * existing installs that never touched the admin switches keep the features they + * have today. A row that already says 'false' is left alone. + */ +export class Migration20200101030500_backfill_the_three_places_feature_toggles_before extends Migration { + override name = 'Migration20200101030500_backfill_the_three_places_feature_toggles_before'; + + override up(): void { + for (const key of PLACES_TOGGLES) { + this.addSql(`INSERT OR IGNORE INTO app_settings (key, value) VALUES ('${key}', 'true')`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts new file mode 100644 index 000000000..2a1a1a7f4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 186 (`db/migrations.ts`). + * + * Free the `note` column on budget items for its actual purpose (#1658). The + * costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which means an + * expense split by receipt can never carry a written note, and saving an expense + * any other way wipes whatever was typed in the budget table. The receipt moves + * to its own column and note becomes text again. + * + * GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note + * starting "ticketjson:" would be chopped up and its text dropped. + */ +export class Migration20200101030600_free_the_note_column_on_budget_items extends Migration { + override name = 'Migration20200101030600_free_the_note_column_on_budget_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'ticket_json', `ticket_json TEXT`); + await this.execute(` + UPDATE budget_items + SET ticket_json = substr(note, 12), note = NULL + WHERE note GLOB 'TICKETJSON:*' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030700_note_colours.ts b/server/src/db/migrations/Migration20200101030700_note_colours.ts new file mode 100644 index 000000000..5b86940d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030700_note_colours.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 187 (`db/migrations.ts`). + * + * Note colours (#1629). A day note is a label as much as a reminder — "watch + * out", "must see", "already booked" — and a wall of identical grey cards + * makes that impossible to see at a glance. NULL keeps the neutral card + * every existing note has today. + */ +export class Migration20200101030700_note_colours extends Migration { + override name = 'Migration20200101030700_note_colours'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'color', `color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts new file mode 100644 index 000000000..4450d004c --- /dev/null +++ b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 188 (`db/migrations.ts`). + * + * Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place + * (booking arrival, morning hotel) stores its mode on the DESTINATION stop. + * NULL = inherit the day default. INERT whenever the previous timeline element + * is itself a place (that place's outgoing leg_transport_mode wins). + * Appended LAST: the array is index-addressed against schema_version, so a slot + * inserted anywhere above this line is simply skipped on every existing database. + */ +export class Migration20200101030800_per_segment_travel_mode_for_boundary_legs extends Migration { + override name = 'Migration20200101030800_per_segment_travel_mode_for_boundary_legs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'day_assignments', + 'incoming_leg_transport_mode', + `incoming_leg_transport_mode TEXT`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101030900_1298.ts b/server/src/db/migrations/Migration20200101030900_1298.ts new file mode 100644 index 000000000..d6d3a43af --- /dev/null +++ b/server/src/db/migrations/Migration20200101030900_1298.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 189 (`db/migrations.ts`). + * + * #1298 — an expense can hang off a place, exactly as it already hangs off a + * reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths + * take the linked expense with the place themselves, so the constraint is a + * backstop for anything that removes a place without going through them. + * Appended LAST: the array is index-addressed against schema_version, so a + * slot inserted above this line never runs on an existing database. + */ +export class Migration20200101030900_1298 extends Migration { + override name = 'Migration20200101030900_1298'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts new file mode 100644 index 000000000..63ac9e6b4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 190 (`db/migrations.ts`). + * + * A reservation_day_positions row only makes sense when its reservation and + * its day are on the same trip. The table carries no trip_id and its two + * foreign keys only ask that the ids exist, so pairs that never belonged + * together could accumulate; the writer refuses them now, and this clears + * whatever an older build let through. This slot keeps the index it shipped + * with on dev — the array is index-addressed against schema_version. + */ +export class Migration20200101031000_a_reservation_day_positions_row_only_makes extends Migration { + override name = 'Migration20200101031000_a_reservation_day_positions_row_only_makes'; + + override up(): void { + this.addSql(` + DELETE FROM reservation_day_positions + WHERE rowid IN ( + SELECT rdp.rowid + FROM reservation_day_positions rdp + JOIN reservations r ON r.id = rdp.reservation_id + JOIN days d ON d.id = rdp.day_id + WHERE d.trip_id <> r.trip_id + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101031100_1939.ts b/server/src/db/migrations/Migration20200101031100_1939.ts new file mode 100644 index 000000000..918eeef15 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031100_1939.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 191 (`db/migrations.ts`). + * + * #1939 — the Google Places and Unsplash keys are instance configuration and now + * resolve out of app_settings (nest/settings/instance-api-keys.ts). This carries + * the value the old resolver would have handed out — the lowest-id admin who has + * one — into that row, so an install that upgrades keeps the key it was + * searching with instead of falling back to OpenStreetMap. + * + * Only where that key already was the whole install's, though. The resolver + * reads the instance row before the caller's own column, so promoting a personal + * key while a second row still holds one would move that member onto a + * stranger's key and a stranger's bill without anyone saying so. As soon as + * another row has a value for the same column nothing is written: everybody + * keeps resolving what they resolved before, and a member with no key of their + * own searches via OpenStreetMap until an admin saves the instance key once in + * the panel. Handing a key to the whole instance is the admin's call, not a + * migration's. + * + * The users columns are left alone: they are still the per-user fallback, and + * nothing here can lose a value. Stored blobs are copied verbatim; both sides + * use the same apiKeyCrypto format, legacy plaintext included. + */ +export class Migration20200101031100_1939 extends Migration { + override name = 'Migration20200101031100_1939'; + + override async up(): Promise { + for (const column of ['maps_api_key', 'unsplash_api_key'] as const) { + const existing = (await this.execute(`SELECT value FROM app_settings WHERE key = ?`, [column])) as { + value: string | null; + }[]; + if (existing[0]?.value) continue; + + const candidates = (await this.execute( + `SELECT id, ${column} AS value FROM users + WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != '' + ORDER BY id ASC LIMIT 1`, + )) as { id: number; value: string }[]; + const row = candidates[0]; + if (!row?.value) continue; + + const otherHolder = await this.execute( + `SELECT 1 AS present FROM users + WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`, + [row.id], + ); + if (otherHolder.length > 0) continue; + + await this.execute( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [column, row.value], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts new file mode 100644 index 000000000..73cf521bb --- /dev/null +++ b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts @@ -0,0 +1,26 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 192 (`db/migrations.ts`). + * + * Capture metadata on the photo itself (#1614): when it was taken and where. + * Both are needed before photos can sit on the Journey map by their own + * coordinates, and before a gallery can be ordered by when a picture was taken + * rather than when it happened to be added. Nullable throughout — most providers + * answer with neither, and a photo without them is normal. + */ +export class Migration20200101031200_capture_metadata_on_the_photo_itself extends Migration { + override name = 'Migration20200101031200_capture_metadata_on_the_photo_itself'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'taken_at', `taken_at TEXT`); + await addColumnIfMissing(this, 'trek_photos', 'lat', `lat REAL`); + await addColumnIfMissing(this, 'trek_photos', 'lng', `lng REAL`); + + // Answering "which photos of this journey have coordinates" without a scan. + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts new file mode 100644 index 000000000..c861df6e6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 193 (`db/migrations.ts`). + * + * A journey shared while the trip is still running reads like a blog, and a + * blog puts the newest entry first (#1614). The owner decides per share link, + * because it is a property of how the link is meant to be read, not of the + * journey. Off by default: an already-published link must not reorder itself + * under its readers. + */ +export class Migration20200101031300_a_journey_shared_while_the_trip_is extends Migration { + override name = 'Migration20200101031300_a_journey_shared_while_the_trip_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_share_tokens', 'newest_first', `newest_first INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts new file mode 100644 index 000000000..f87a6f133 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 194 (`db/migrations.ts`). + * + * TREK Studio books (#1973). + * One row per book, the document itself stored as JSON. A book is a + * document rather than a graph of records: the editor loads it whole, the + * renderer prints it whole, and nothing ever queries "which books contain a + * heart-shaped frame". Normalising spreads and elements into tables would + * buy queries nobody makes and cost a join on every open plus a schema + * migration for every new element kind. + * `updated_at` and `version` are what make concurrent editing possible: + * the version increments on every write, and a client that saves against a + * version it did not read gets told rather than silently overwriting. + */ +export class Migration20200101031400_trek_studio_books extends Migration { + override name = 'Migration20200101031400_trek_studio_books'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + document TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts new file mode 100644 index 000000000..14afbb064 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts @@ -0,0 +1,79 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Legacy migration step 195 (`db/migrations.ts`). + * + * Great Britain gained its counties and boroughs back (#1974). + * + * geoBoundaries' GBR ADM1 is the four constituent countries, so since 3.1.0 the + * atlas had no polygon below "England" and a London borough marked as visited + * had nothing to light up. The bundle now ships GB at ADM2, which is the + * granularity the rest of the world gets and the granularity the pre-3.1.0 + * Natural Earth layer had. + * + * Two things need saying to the data that already exists: + * + * `visited_regions` rows marked before 3.1.0 hold the old Natural Earth borough + * codes. They are reconciled by name within the same country, exactly as the + * 3.1.1 step does for every other country — a row that still cannot be resolved + * is left alone rather than destroyed, and the client's name fallback may well + * still highlight it. + * + * `place_regions` is a re-derivable cache, and it is full of GB-ENG rows that + * were correct under the old bundle. Nothing re-derives a row that is already + * there, so without clearing them every English place would keep reporting + * "England" forever. + */ +export class Migration20200101031500_great_britain_gained_its_counties_and_boroughs extends Migration { + override name = 'Migration20200101031500_great_britain_gained_its_counties_and_boroughs'; + + override async up(): Promise { + await this.execute(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`); + + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`, + )) as VisitedRegion[]; + if (rows.length === 0) return; + + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + return; // bundle unreadable — leave the rows untouched rather than guessing + } + + const validCodes = new Set(); + const nameToCode = new Map(); + for (const f of features) { + const p = f.properties || {}; + if ((p.iso_a2 || '').toUpperCase() !== 'GB' || !p.iso_3166_2) continue; + validCodes.add(p.iso_3166_2); + if (p.name) nameToCode.set(p.name.toLowerCase(), p.iso_3166_2); + } + if (validCodes.size === 0) return; + + for (const row of rows) { + if (validCodes.has(row.region_code)) continue; + const byName = nameToCode.get((row.region_name || '').toLowerCase()); + if (byName) { + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ? WHERE id = ?`, [byName, row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts new file mode 100644 index 000000000..bf8ec5eb1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 196 (`db/migrations.ts`). + * + * Storage slice 2 — collab note attachments historically stored 'files/' + * in trip_files.filename while the file manager stored bare names in the same + * column; the storage layer addresses objects as category + bare name, so + * normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of + * 'files/'. The LIKE guard makes it a no-op on already-bare rows. + * Appended LAST again, and for the same reason as the note this replaces: + * the array is index-addressed against schema_version, so a slot that has + * shipped in dev keeps the index it shipped with and anything from this + * branch goes after it. A database that already ran this migration at its + * pre-merge index replays it harmlessly (the LIKE guard) but skips whatever + * now occupies that index. Acceptable only because this branch has never + * been published; never do this with a released slot. + */ +export class Migration20200101031600_storage_slice_2 extends Migration { + override name = 'Migration20200101031600_storage_slice_2'; + + override up(): void { + this.addSql(`UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'`); + } +} diff --git a/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts new file mode 100644 index 000000000..e646fb9a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 197 (`db/migrations.ts`). + * + * Give back the notes the TICKETJSON step chopped up (#1658). + * + * That step matched with LIKE, which is case-insensitive in SQLite, so a note + * somebody had typed starting "ticketjson:" was read as a receipt: the first + * eleven characters were dropped, the rest was moved into ticket_json and the + * note was set to NULL. The step now matches with GLOB, but that only helps a + * fresh install — the legacy array was index-addressed against schema_version, + * so a repaired step never ran again on a database that had already applied it. + * + * A receipt comes out of JSON.stringify and always parses; typed text does not. + * So a row moves back only when its ticket_json fails to parse AND the note is + * still empty — anything that parses, and any row someone has written a note on + * since, is left exactly as it is. The eleven marker characters themselves are + * gone for good: their case was the only thing separating a receipt from a note, + * and writing "TICKETJSON:" back would hand the row straight to the reader that + * reads that prefix as a receipt. + */ +export class Migration20200101031700_give_back_the_notes_the_ticketjson_step extends Migration { + override name = 'Migration20200101031700_give_back_the_notes_the_ticketjson_step'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, ticket_json FROM budget_items + WHERE ticket_json IS NOT NULL AND ticket_json != '' AND COALESCE(note, '') = ''`, + )) as { id: number; ticket_json: string }[]; + + for (const row of rows) { + try { + JSON.parse(row.ticket_json); + } catch { + await this.execute(`UPDATE budget_items SET note = ?, ticket_json = NULL WHERE id = ?`, [ + row.ticket_json, + row.id, + ]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts new file mode 100644 index 000000000..6b2ff866f --- /dev/null +++ b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 198 (`db/migrations.ts`). + * + * A deliberate non-latest plugin install sets `update_hold`: the row leaves the + * update banner and "Update all" until the admin resumes updates, or until an + * install lands back on the newest compatible version. Only an EXPLICIT version + * pick ever sets it — dependency resolution pins versions too, but never + * deliberately. Appended LAST — the array is index-addressed against schema_version. + */ +export class Migration20200101031800_a_deliberate_non_latest_plugin_install_sets extends Migration { + override name = 'Migration20200101031800_a_deliberate_non_latest_plugin_install_sets'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_hold', `update_hold INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts new file mode 100644 index 000000000..ce8de24ec --- /dev/null +++ b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 199 (`db/migrations.ts`). + * + * Reservations an automated ingest parked for review are 'staged' and stay out + * of the two anonymous exports (ICS feed, shared trip) until a person confirms + * them. Nothing writes 'staged' yet; this is the gate the mail ingest writes + * through. + * + * 'live' as the default is what keeps this from being a breaking change: SQLite + * fills every existing row on the ALTER, and no current writer names the column, + * so every booking that is visible today stays visible. + */ +export class Migration20200101031900_reservations_an_automated_ingest_parked_for_review extends Migration { + override name = 'Migration20200101031900_reservations_an_automated_ingest_parked_for_review'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'ingest_state', `ingest_state TEXT NOT NULL DEFAULT 'live'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts new file mode 100644 index 000000000..9d19028f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 200 (`db/migrations.ts`). + * + * Separate an integration key from an MCP token (#2089). + * Both live in mcp_tokens and until now both opened everything a token can + * open. That was fine while /mcp was the only consumer; with a public REST + * surface it means a key somebody minted for a chat client also reads their + * trips over HTTP, and a key minted for an integration can drive every MCP + * tool. One credential, two very different blast radii. + * `kind` splits them, and every existing row becomes 'mcp' — that is what + * they were issued for, and silently widening a key that is already in + * somebody's config would be the opposite of what this migration is for. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032000_separate_an_integration_key_from_an_mcp extends Migration { + override name = 'Migration20200101032000_separate_an_integration_key_from_an_mcp'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'kind', `kind TEXT NOT NULL DEFAULT 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts new file mode 100644 index 000000000..2bf2947ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 201 (`db/migrations.ts`). + * + * `naver_list_import` was typed 'trip', but a trip addon is one that earns its + * own tab inside a trip — this one has no tab (it is not in tripTabs.ts) and no + * page. It calls an external service to pull places into the sidebar, which is + * exactly what 'integration' means here. + * + * The type is presentational: only `type === 'global'` is read anywhere (client + * navItems.ts), so this moves the tile between admin groups and changes nothing + * about how the import behaves. + */ +export class Migration20200101032100_naver_list_import_was_typed_trip_but extends Migration { + override name = 'Migration20200101032100_naver_list_import_was_typed_trip_but'; + + override up(): void { + this.addSql(`UPDATE addons SET type = 'integration' WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts new file mode 100644 index 000000000..2d9520e11 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 202 (`db/migrations.ts`). + * + * Whether a journey's map draws the GPX tracks of the trips behind it (#2194). + * #1260 added those tracks unconditionally and with nothing to switch off, + * on the reasoning that a route imported into a linked trip is part of the + * journey's story. For a trip carrying a season of recorded drives it is + * instead a map nobody asked for, drawn from places that never became an + * entry — so it becomes a journey-level setting. + * DEFAULT 0, i.e. off: the tracks are opt-in from here on. That is a + * deliberate behaviour change rather than a preserved default — a journal + * should show what its author put in it, and #1260's set is everything the + * linked trips happen to contain. Owners who want them back have one + * switch in Journey Settings. + */ +export class Migration20200101032200_whether_a_journey_s_map_draws_the extends Migration { + override name = 'Migration20200101032200_whether_a_journey_s_map_draws_the'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'show_trip_tracks', `show_trip_tracks INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts new file mode 100644 index 000000000..fab647aaf --- /dev/null +++ b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 203 (`db/migrations.ts`). + * + * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the + * field's effective value when nothing is stored — the settings form pre-fills it and + * the runtime resolves it (settings-defaults.ts); it was previously accepted by the + * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032300_settings_field_defaults extends Migration { + override name = 'Migration20200101032300_settings_field_defaults'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugin_settings_fields', 'default_value', `default_value TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts new file mode 100644 index 000000000..6073ad8ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 204 (`db/migrations.ts`). + * + * Settings-form actions gain a scope (#plugins): 'user' renders on the user + * Settings tab, 'instance' in the admin instance-settings dialog. Existing rows + * predate the column and were all user-tab buttons, so the default keeps them + * where they were. + */ +export class Migration20200101032400_settings_form_actions_gain_a_scope extends Migration { + override name = 'Migration20200101032400_settings_form_actions_gain_a_scope'; + + override async up(): Promise { + // An empty column list means the table does not exist on this database. + const names = await columnNames(this, 'plugin_actions'); + if (names.size === 0) return; + await addColumnIfMissing(this, 'plugin_actions', 'scope', `scope TEXT NOT NULL DEFAULT 'user'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts new file mode 100644 index 000000000..751ec3b77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 205 (`db/migrations.ts`). + * + * A journal entry that is not a stop (discussion #2064). + * Studio draws its route and prints its distance from every entry that + * carries coordinates, and that is right until the journal starts at the + * home airport: the night before the flight, the stopover, the place the + * trip was planned from all become stops, and the distance counts the legs + * to and from them. The traveller knows which of those are the journey and + * which are the way there, so the switch sits on the entry. The entry stays + * in the journal; it is only left out of the arithmetic. + * DEFAULT 0: every existing entry keeps counting, which is what it did. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032500_a_journal_entry_that_is_not_a extends Migration { + override name = 'Migration20200101032500_a_journal_entry_that_is_not_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'stats_excluded', `stats_excluded INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts new file mode 100644 index 000000000..d06f4b20b --- /dev/null +++ b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 206 (`db/migrations.ts`). + * + * Place shadow log: which search result a user actually picked. + * The corpus behind "would our own index have found that too". No user id, + * no trip, no session and no result list — the evaluation compares a query + * against a pick, and anything beyond that would be collecting for its own + * sake on an instance that promises not to. + * The table is created regardless of the switch: the switch decides whether + * rows are written, and a schema that appears only when a feature is + * enabled is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032600_place_shadow_log_which_search_result_a extends Migration { + override name = 'Migration20200101032600_place_shadow_log_which_search_result_a'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_shadow_picks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + query TEXT NOT NULL, + lang TEXT, + bias_lat REAL, + bias_lng REAL, + source TEXT NOT NULL, + live_rank INTEGER NOT NULL, + live_count INTEGER NOT NULL, + picked_name TEXT NOT NULL, + picked_lat REAL NOT NULL, + picked_lng REAL NOT NULL, + picked_place_id TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts new file mode 100644 index 000000000..c7f6c6d2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 207 (`db/migrations.ts`). + * + * What kind of stop a place is on a drive — fuel, charging, rest area, campsite. + * Deliberately NOT a `categories` row. Those are the traveller's own list, editable + * and instance-wide (`categories.service.ts` selects them without a user filter), so + * seeding four road-trip kinds there would push them into everyone's dropdown and hand + * their colour to whoever edits the list first. A refuelling stop is not a taste; it is + * a fact about the place, and the road-trip categories already own its icon and colour + * (`poiCategories.ts`). + * Free text rather than a CHECK constraint: the set grows with what the corridor + * search can look for, and SQLite cannot alter a constraint without rebuilding the + * table. NULL means an ordinary place, which is every row that exists today. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032700_what_kind_of_stop_a_place_is extends Migration { + override name = 'Migration20200101032700_what_kind_of_stop_a_place_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'stop_type', `stop_type TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts new file mode 100644 index 000000000..0c79f8750 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 208 (`db/migrations.ts`). + * + * Points a day's drive is made to pass through, without being stops (#1797). + * The difference is the whole point: a stop is somewhere you go, and it takes a + * number in the chain, a place row, an arrival time and a line in the itinerary. A + * via is none of that — it only bends the route, which is what "take the coast road + * instead" means. Storing one as a place was the alternative, and it would have put a + * numbered stop in the middle of the day for a spot nobody stops at. + * Anchored to `after_order_index` rather than to an assignment id: a stop added + * mid-day is written with a temporary negative id and swapped for the real one moments + * later, so a foreign key to it would dangle. The index is what the routing request is + * built from anyway. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032800_points_a_day_s_drive_is_made extends Migration { + override name = 'Migration20200101032800_points_a_day_s_drive_is_made'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_vias ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + after_order_index INTEGER NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + lat REAL NOT NULL, + lng REAL NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts new file mode 100644 index 000000000..3e0a2e96d --- /dev/null +++ b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 209 (`db/migrations.ts`). + * + * Which imported track a day's drive was fitted to. + * The vias alone already make the day follow it, so this stores nothing the drive + * needs — it stores what the traveller needs: the name of the road they chose, still + * on the day after a reload, and something to re-fit against when the stops change. + * One row per day, hence `day_id` as the key: a day follows one road or none. The + * cascade on `place_id` is the point of the foreign key — delete the imported track + * and the label goes with it, rather than leaving a day claiming to follow a line + * nobody can see any more. The vias it laid down stay, because they are the drive. + * `stray_km` is how far the fitted route still ran from the track at its worst point, + * kept so the day can say how good a fit it is without routing again. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032900_which_imported_track_a_day_s_drive extends Migration { + override name = 'Migration20200101032900_which_imported_track_a_day_s_drive'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_tracks ( + day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + stray_km REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts new file mode 100644 index 000000000..777db83ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +const OLD_MATCH = `(region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`; + +/** + * Legacy migration step 210 (`db/migrations.ts`). + * + * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). + * + * geoBoundaries labelled the whole province with the name of its capital, so + * every row a user collected under it carries the wrong code. All three tables + * that key on a region are moved over: the two per-user ones with an + * `UPDATE OR IGNORE` plus a `DELETE`, because a user who already holds the + * correct region would otherwise hit the unique index and keep a duplicate, and + * `place_regions` with a plain UPDATE, because place_id is its primary key and + * nothing there can collide. + */ +export class Migration20200101033000_rename_the_misnamed_guangdong_province extends Migration { + override name = 'Migration20200101033000_rename_the_misnamed_guangdong_province'; + + override up(): void { + this.addSql(` + UPDATE OR IGNORE visited_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + this.addSql(`DELETE FROM visited_regions WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH}`); + + this.addSql(` + UPDATE OR IGNORE place_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + + // hidden_regions is the other direction: it remembers which derived region a + // user switched off. Left behind, the tombstone stops matching and the region + // a user deliberately hid comes back. + this.addSql(` + UPDATE OR IGNORE hidden_regions + SET region_code = 'CN-GUANGDONGPROVINCE' + WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE' + `); + this.addSql(`DELETE FROM hidden_regions WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`); + } +} diff --git a/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts new file mode 100644 index 000000000..231eb95bc --- /dev/null +++ b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts @@ -0,0 +1,38 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 211 (`db/migrations.ts`). + * + * Let a file hang off an expense, so a receipt or an invoice can be attached to + * what it paid for. + * + * A column on `file_links` rather than a table of its own: the row already ties + * one file to one thing, and every other attachment kind is a column here too. + * The unique index stops the same receipt being linked twice; SQLite treats + * NULLs as distinct, so the rows that exist today, which all carry a NULL here, + * do not collide with each other. + * + * SET NULL rather than CASCADE, unlike the three columns beside it, because + * those predate the shared row: one row can carry a place link AND a receipt + * link for the same file, and a cascade would delete the whole row when the + * expense goes, silently detaching the file from the place as well. Deleting the + * expense drops the receipt link and nothing else; the budget service removes + * the row afterwards when it carries no other link. + */ +export class Migration20200101033100_let_a_file_hang_off_an_expense extends Migration { + override name = 'Migration20200101033100_let_a_file_hang_off_an_expense'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'file_links', + 'budget_item_id', + `budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL`, + ); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts new file mode 100644 index 000000000..6a1c0e49c --- /dev/null +++ b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 212 (`db/migrations.ts`). + * + * Which chat message an uploaded image belongs to. + * + * A column on `trip_files` rather than a link row, matching the two that predate + * it: a chat image is uploaded for exactly one message and dies with it, so the + * cascade is the whole relationship. Guarded through pragma_table_info like + * every other column add here, not through a caught "duplicate column name": + * that swallows the next error too. + */ +export class Migration20200101033200_which_chat_message_an_uploaded_image_belongs extends Migration { + override name = 'Migration20200101033200_which_chat_message_an_uploaded_image_belongs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'message_id', + `message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts new file mode 100644 index 000000000..2bf7d0256 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 213 (`db/migrations.ts`). + * + * Links somebody shared with the trip. + * Its own table rather than a note with a URL in it: a link is pinned, + * ordered and opened, and none of that is what a note does. `user_id` is who + * shared it, so the list can say so and so a member leaving takes their rows + * with them. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033300_links_somebody_shared_with_the_trip extends Migration { + override name = 'Migration20200101033300_links_somebody_shared_with_the_trip'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts new file mode 100644 index 000000000..6e3f6d4de --- /dev/null +++ b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 214 (`db/migrations.ts`). + * + * Route usage counters — how much routing this instance really does. + * Daily aggregates, not a log: one row per day, profile, surface and engine + * kind, carrying totals. No query, no coordinate, no route, no user, no trip. + * The question they answer is whether TREK could host a router itself, and + * that needs volume, not itineraries. + * The table is created regardless of the switch, like the shadow log above: + * the switch decides whether rows are written, and a schema that appears only + * when a feature is on is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033400_route_usage_counters extends Migration { + override name = 'Migration20200101033400_route_usage_counters'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS route_usage_daily ( + day TEXT NOT NULL, + profile TEXT NOT NULL, + surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, + km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts new file mode 100644 index 000000000..3c3115f66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 215 (`db/migrations.ts`). + * + * How full THIS stop fills the tank, 1 to 100 (#1797). + * Beside stop_type rather than in the traveller's settings, because it is a property + * of the stop and not of the person: a motorway rapid charger gets 80 % because the + * last fifth would cost as long again, while the one at the hotel gets 100 % because + * the car stands there all night. One figure for the whole trip cannot say both, and + * the difference between them is a leg. + * NULL means "whatever the traveller's own setting says", which is every row that + * exists today and every stop nobody has an opinion about. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033500_how_full_this_stop_fills_the_tank extends Migration { + override name = 'Migration20200101033500_how_full_this_stop_fills_the_tank'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'fill_percent', `fill_percent INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts new file mode 100644 index 000000000..f02f4d097 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 216 (`db/migrations.ts`). + */ +export class Migration20200101033600_create_school_holiday_countries extends Migration { + override name = 'Migration20200101033600_create_school_holiday_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_countries ( + code TEXT PRIMARY KEY, name TEXT NOT NULL + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + country TEXT NOT NULL REFERENCES school_holiday_countries(code), + name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(country, name) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE, + name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, + CHECK (end_date >= start_date) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts new file mode 100644 index 000000000..79a868080 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 217 (`db/migrations.ts`). + */ +export class Migration20200101033700_day_assignments_add_end_day extends Migration { + override name = 'Migration20200101033700_day_assignments_add_end_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'end_day', `end_day INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts new file mode 100644 index 000000000..bfff4f9ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 218 (`db/migrations.ts`). + */ +export class Migration20200101033800_create_roadtrip_day_boundaries extends Migration { + override name = 'Migration20200101033800_create_roadtrip_day_boundaries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts new file mode 100644 index 000000000..691c5e469 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; +import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; + +/** + * Legacy migration step 219 (`db/migrations.ts`). + * + * Per-trip road-trip preferences, seeded from whatever the trip owner had set + * globally so an existing trip keeps behaving the way it did. + */ +export class Migration20200101033900_create_roadtrip_preferences extends Migration { + override name = 'Migration20200101033900_create_roadtrip_preferences'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))`, + ); + for (const key of ROADTRIP_PREFERENCE_KEYS) { + await this.execute( + `INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL`, + [key], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts new file mode 100644 index 000000000..01e3bd0a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 220 (`db/migrations.ts`). + * + * A settle-up payment's date was silently `created_at` (when it was recorded), + * not editable like a regular expense's `expense_date`. Add the same split: + * settled_at is the calendar day the transfer actually happened, independent + * of when someone got around to logging it. NULL on legacy rows and rows + * whose caller didn't set it; the read side falls back to created_at's date. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101034000_a_settle_up_payment_s_date_was extends Migration { + override name = 'Migration20200101034000_a_settle_up_payment_s_date_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'settled_at', `settled_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034100_amap.ts b/server/src/db/migrations/Migration20200101034100_amap.ts new file mode 100644 index 000000000..5ff3e190b --- /dev/null +++ b/server/src/db/migrations/Migration20200101034100_amap.ts @@ -0,0 +1,29 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 221 (`db/migrations.ts`). + * + * Amap (高德) as a keyed places provider. + * + * `users.amap_api_key` has the same shape and role as maps_api_key: encrypted + * with apiKeyCrypto, and the last step of the resolver after the operator env + * var and the instance-wide app_settings row. Nullable with no default, because + * "this install does not use Amap" is the correct state for almost everybody. + * Nothing is backfilled: a Google key is not an Amap key, and the two are chosen + * by the places_provider setting, not by which column happens to be populated. + * + * `places.amap_poi_id` is the provider id a place was found by, beside + * google_place_id and osm_id. Amap ids are bare strings shaped like Google ones, + * so the column holds them with the `amap:` prefix the maps domain uses + * everywhere, and a place keeps opening against Amap after the admin switches + * provider. + */ +export class Migration20200101034100_amap extends Migration { + override name = 'Migration20200101034100_amap'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'amap_api_key', `amap_api_key TEXT`); + await addColumnIfMissing(this, 'places', 'amap_poi_id', `amap_poi_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts new file mode 100644 index 000000000..af41085a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 222 (`db/migrations.ts`). + * + * Dawarich connection (#2279). Its own table rather than more `users` columns, + * which is where the AirTrail connection lives: that was the right call while + * AirTrail was the only integration of this shape, and this is the second. It + * also carries sync state (cursor, last error, probed capabilities) that has no + * business sitting on the identity row. Credentials for a personal location + * archive stay strictly apart from anything shared on a trip. + */ +export class Migration20200101034200_dawarich_connection extends Migration { + override name = 'Migration20200101034200_dawarich_connection'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_connections ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + url TEXT, + api_key TEXT, + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + capabilities TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts new file mode 100644 index 000000000..a523bedf6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 223 (`db/migrations.ts`). + * + * Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — + * the UNIQUE is what makes a repeated sync idempotent instead of duplicating + * everything it already imported. + * `source_hash` exists because a Dawarich visit carries no `updated_at`: a + * rename or a re-detection is only visible as a different hash of the fields + * TREK shows. `source_missing_at` exists because deleting a visit removes it + * from the API rather than tombstoning it, so absence from a full re-read of + * the same window is the only signal — and it is recorded, not acted on, + * because an entry the user already accepted and edited is theirs now. + */ +export class Migration20200101034300_reviewed_visit_suggestions extends Migration { + override name = 'Migration20200101034300_reviewed_visit_suggestions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_visit_id TEXT NOT NULL, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 0, + local_date TEXT NOT NULL, + source_status TEXT NOT NULL DEFAULT 'suggested', + confidence REAL, + confidence_band TEXT, + country_code TEXT, + state TEXT NOT NULL DEFAULT 'new', + target TEXT, + accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + accepted_journal_entry_id INTEGER, + accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + source_hash TEXT NOT NULL, + accepted_hash TEXT, + source_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_visit_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts new file mode 100644 index 000000000..dc95e0d36 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 224 (`db/migrations.ts`). + * + * Bucket-list entries can now be ticked off (#2279). `visited_source` records + * who decided — a hand-ticked wish and one confirmed from a recording read the + * same on the map otherwise, and re-running a scan must not touch the first. + */ +export class Migration20200101034400_bucket_list_entries_can_now_be_ticked extends Migration { + override name = 'Migration20200101034400_bucket_list_entries_can_now_be_ticked'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'visited_at', `visited_at TEXT`); + await addColumnIfMissing(this, 'bucket_list', 'visited_source', `visited_source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts new file mode 100644 index 000000000..c081309f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 225 (`db/migrations.ts`). + * + * Where an Atlas country came from (#2279). Everything that exists today was + * marked by hand, so 'manual' is the correct backfill rather than a guess; + * countries confirmed out of a recording are written as 'dawarich' and the + * Atlas can say so. Unmarking still deletes the row either way. + */ +export class Migration20200101034500_where_an_atlas_country_came_from extends Migration { + override name = 'Migration20200101034500_where_an_atlas_country_came_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'visited_countries', 'source', `source TEXT NOT NULL DEFAULT 'manual'`); + } +} diff --git a/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts new file mode 100644 index 000000000..5269436ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 226 (`db/migrations.ts`). + * + * Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which + * sections a *response* carries; it has never restricted what a key may read, + * and handing an integration a key that reads every trip because it wanted day + * notes is the thing that needed fixing. + * + * `scope_mode` is an explicit flag rather than "NULL means everything": a + * sentinel here would mean a bug that drops the scopes column silently grants + * full access. Every existing key is 'all', so nothing that works today stops + * working — the restriction is opt-in at mint time. + */ +export class Migration20200101034600_per_key_read_scopes_for_api_v1 extends Migration { + override name = 'Migration20200101034600_per_key_read_scopes_for_api_v1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'scope_mode', `scope_mode TEXT NOT NULL DEFAULT 'all'`); + await addColumnIfMissing(this, 'mcp_tokens', 'api_scopes', `api_scopes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts new file mode 100644 index 000000000..4a1435ee8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 227 (`db/migrations.ts`). + * + * Where a place came from, when it did not come from a person typing it + * (#2279). Only 'dawarich' writes it today; NULL is every place anyone has + * ever added by hand, which is what it should stay. + * A column rather than a lookup through dawarich_visit_suggestions: the mark + * has to survive the suggestion being deleted, and a place list would + * otherwise join an integration's table to render a name. + */ +export class Migration20200101034700_where_a_place_came_from_when_it extends Migration { + override name = 'Migration20200101034700_where_a_place_came_from_when_it'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'source', `source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts new file mode 100644 index 000000000..c03d60186 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 228 (`db/migrations.ts`). + * + * Provenance for a day stop that a lodging booking put there rather than the + * traveller: it carries the stay's id, so moving or deleting the booking can + * move or delete exactly that stop and never one somebody placed by hand. + * Every row that already exists stays NULL: those were planned by hand, and a + * booking must not start claiming ownership of them. The step below adds the + * missing stops instead, which is a different thing from claiming old ones. + */ +export class Migration20200101034800_provenance_for_a_day_stop_that_a extends Migration { + override name = 'Migration20200101034800_provenance_for_a_day_stop_that_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'accommodation_id', `accommodation_id INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts new file mode 100644 index 000000000..394387b1f --- /dev/null +++ b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts @@ -0,0 +1,26 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 229 (`db/migrations.ts`). + * + * Give every stay booked before this release the day stop it would get today. + * + * Road trip mode builds its drive out of day_assignments alone, so a hotel + * booked in Days mode was invisible there and the traveller had to add the same + * place a second time by hand. New bookings get the stop as they are written; + * without this step the fix would only ever apply to trips planned after the + * upgrade, and the trips people already have would stay broken. + * + * Skipped on purpose: a stay whose place is gone (place_id is ON DELETE SET + * NULL, and the booking form writes stays that never had one), and a place the + * traveller already planned for that day, whose row stays theirs and unmarked. + */ +export class Migration20200101034900_give_every_stay_booked_before_this_release extends Migration { + override name = 'Migration20200101034900_give_every_stay_booked_before_this_release'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Put ${placed} booked night(s) on their check-in day`); + } +} diff --git a/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts new file mode 100644 index 000000000..c74345154 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts @@ -0,0 +1,27 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 230 (`db/migrations.ts`). + * + * The same sweep once more, for the nights the first one could not have seen. + * + * A migration runs while the old container is still answering: a booking written + * in those seconds is written by code that knows nothing about the day stop, and + * lands behind the sweep that would have given it one. One did, on the test + * instance, out of ten. There is nothing to be done about that window, but there + * is something to be done about what falls into it, and a booking that never got + * its stop is invisible to the drive with no way back short of saving it again. + * + * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its + * stop is passed over, and one whose place the traveller planned by hand keeps + * that row unclaimed. + */ +export class Migration20200101035000_the_same_sweep_once_more_for_the extends Migration { + override name = 'Migration20200101035000_the_same_sweep_once_more_for_the'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Caught up ${placed} booked night(s) missed during the upgrade`); + } +} diff --git a/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts new file mode 100644 index 000000000..94173bda8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 231 (`db/migrations.ts`). + * + * A suggestion the traveller has waved away. + * Skeletons are real rows, and the trip sync decides what to create by asking + * which source places already have one. Deleting a dismissed suggestion would + * therefore bring it straight back on the next sync. So it stays, marked, and + * drops out of every read instead — which also leaves a way back. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101035100_a_suggestion_the_traveller_has_waved_away extends Migration { + override name = 'Migration20200101035100_a_suggestion_the_traveller_has_waved_away'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'dismissed', `dismissed INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts new file mode 100644 index 000000000..cc2f92d7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 232 (`db/migrations.ts`). + * + * The country an entry happened in, resolved once from its coordinates. + * For the flag on the timeline card. Resolved on write rather than on read + * because the answer never changes and the polygon test should not run on + * every render of every entry. + */ +export class Migration20200101035200_the_country_an_entry_happened_in_resolved extends Migration { + override name = 'Migration20200101035200_the_country_an_entry_happened_in_resolved'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'country_code', `country_code TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts new file mode 100644 index 000000000..494f30d2e --- /dev/null +++ b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 233 (`db/migrations.ts`). + * + * Which of the optional entry fields a journey uses. + * + * Mood, weather and the pros/cons list are the three things that make the editor + * feel like a form. Not everybody journals that way, and a journey kept by one + * person for their family should be able to put them away without the fields + * being taken from everybody else (discussion #2299). + * + * DEFAULT 1: every existing journey keeps all three, which is what it had. + */ +export class Migration20200101035300_which_of_the_optional_entry_fields_a extends Migration { + override name = 'Migration20200101035300_which_of_the_optional_entry_fields_a'; + + override async up(): Promise { + for (const column of ['show_verdict', 'show_mood', 'show_weather']) { + await addColumnIfMissing(this, 'journeys', column, `${column} INTEGER NOT NULL DEFAULT 1`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts new file mode 100644 index 000000000..f809b0a53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 234 (`db/migrations.ts`). + * + * Let go of a booking the day stop can no longer reach. + * day_assignments.accommodation_id was added as a bare INTEGER, and the only + * code that ever clears it looks the stay up by id. A stay can also vanish + * without anybody asking: day_accommodations.end_day_id is ON DELETE CASCADE, + * so shortening a trip past a booking's last night deletes the booking while + * the stop on its first night survives, now pointing at nothing. Days hides any + * stop that carries an accommodation_id, so the hotel drops out of the day list + * on every surface while the route still drives to it, and the id is + * AUTOINCREMENT, so nothing will ever come along and free the row. + * A trigger rather than a column rebuild: day_assignments is referenced by two + * cascading tables of its own, and SQLite fires this even when the stay went + * down with a foreign-key cascade. + */ +export class Migration20200101035400_let_go_of_a_booking_the_day extends Migration { + override name = 'Migration20200101035400_let_go_of_a_booking_the_day'; + + override up(): void { + this.addSql(` + UPDATE day_assignments SET accommodation_id = NULL + WHERE accommodation_id IS NOT NULL + AND accommodation_id NOT IN (SELECT id FROM day_accommodations) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_accommodation_id ON day_assignments(accommodation_id)`); + + this.addSql(` + CREATE TRIGGER IF NOT EXISTS trg_release_stop_on_stay_delete + AFTER DELETE ON day_accommodations + BEGIN + UPDATE day_assignments SET accommodation_id = NULL WHERE accommodation_id = OLD.id; + END + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts new file mode 100644 index 000000000..a7e8650db --- /dev/null +++ b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts @@ -0,0 +1,55 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 235 (`db/migrations.ts`). + * + * A journal skeleton belongs to a day, not just to a place (#2329). + * + * The sync engine keyed a skeleton by `source_place_id` alone, so the same place + * standing on two days — the city you land in at dusk and walk through the next + * morning, the hotel you sleep in three nights — produced one entry on the first + * of them and nothing on the rest. There was nowhere to put the second evening's + * photographs but the first evening's entry. + * + * The assignment row, not the day, is the missing half of that key: moving a + * stop to another day updates `day_assignments.day_id` in place, so keying on + * the assignment lets an entry follow the move the way it always has, while + * still telling two days of the same place apart. + * + * Deliberately no REFERENCES clause. Unassigning a stop has to leave the id + * stale rather than NULL, because reconciliation reads a key that no longer + * matches as "this stop left the plan" — which is what happened — and a NULL + * would be indistinguishable from a row this migration could not resolve. The id + * is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never + * handed out twice. + * + * Existing rows are backfilled to the place's earliest assignment, which is the + * one the old engine would have picked, so reconciliation matches what is + * already there instead of writing a duplicate beside it. + */ +export class Migration20200101035500_a_journal_skeleton_belongs_to_a_day extends Migration { + override name = 'Migration20200101035500_a_journal_skeleton_belongs_to_a_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'source_assignment_id', `source_assignment_id INTEGER`); + + // `source_assignment_id IS NULL` keeps the replay from re-resolving a row + // that has since followed its stop to another day. + await this.execute(` + UPDATE journey_entries + SET source_assignment_id = ( + SELECT da.id + FROM day_assignments da + JOIN days d ON d.id = da.day_id + WHERE da.place_id = journey_entries.source_place_id + ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC + LIMIT 1 + ) + WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL + `); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts new file mode 100644 index 000000000..aca1edbcd --- /dev/null +++ b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts @@ -0,0 +1,69 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 236 (`db/migrations.ts`). + * + * Document providers, part 1 of 3: the registry (#214). + * + * Deliberately its own pair of tables rather than a `kind` column on + * `photo_providers`. The client filters on `type === 'photo_provider'`, the + * Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no WHERE + * clause, and migrations are append-only. Reusing those tables would change the + * behaviour of three existing paths, which is exactly what the + * no-breaking-changes rule forbids. The field columns follow + * `photo_provider_fields` except for `settings_key` and `payload_key`. Those map + * a photo field onto a settings key and a request key; a document field is + * stored under its own `field_key` in one JSON column, and the connect form + * takes its fields from /docsync/providers rather than from the generic settings + * form, so nothing would ever read them. + * + * The rows come from the same constants `seeds.ts` uses, so a fresh install and + * an upgraded one agree. + */ +export class Migration20200101035600_document_providers_part_1_of_3_the extends Migration { + override name = 'Migration20200101035600_document_providers_part_1_of_3_the'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS document_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'FileText', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS document_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + for (const p of DOCUMENT_PROVIDERS) { + await this.execute( + `INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)`, + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await this.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts new file mode 100644 index 000000000..19c8c43e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts @@ -0,0 +1,93 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 237 (`db/migrations.ts`). + * + * Document providers, part 2 of 3: the connection, and the trip binding. + * The connection carries a `trip_id`, and that is the one place this design + * departs from every integration already in the repo. Immich, Synology + * Photos, AirTrail and Dawarich all hang off a user, and even + * `trip_album_links` carries a `user_id`; photos become visible to the rest + * of a trip only through an opt-in `shared` flag. None of that can satisfy + * "everyone on the trip sees the same documents": it would make a + * document's visibility depend on whose credentials fetched it. So the trip + * admin binds the trip once, the server talks to the provider under that + * single identity, and TREK's own membership decides who sees what. + * `owner_user_id` stays separate from `trip_id` so the credential holder is + * always explicit: when that person leaves the trip the binding goes to + * `orphaned` rather than silently continuing to use an ex-member's token. + * Secrets live in one encrypted JSON blob instead of per-provider columns: + * a sixth provider then needs no migration, and the key rotation in + * scripts/migrate-encryption.ts stays one line instead of a field list that + * someone will forget, and a forgotten column does not survive a rotation. + */ +export class Migration20200101035700_document_providers_part_2_of_3_the extends Migration { + override name = 'Migration20200101035700_document_providers_part_2_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + base_url TEXT NOT NULL, + secrets TEXT, + settings TEXT NOT NULL DEFAULT '{}', + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + capabilities TEXT, + last_probe_at TEXT, + last_probe_state TEXT NOT NULL DEFAULT 'never', + last_probe_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, provider_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_document_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + remote_scope_key TEXT NOT NULL, + remote_root_id TEXT, + remote_root_path TEXT, + remote_label TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL DEFAULT 'both', + delete_policy TEXT NOT NULL DEFAULT 'unlink', + conflict_policy TEXT NOT NULL DEFAULT 'manual', + sync_enabled INTEGER NOT NULL DEFAULT 1, + webhook_token TEXT, + webhook_secret TEXT, + webhook_subscription_id TEXT, + remote_cursor TEXT, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, connection_id, remote_scope_key) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at)`, + ); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token + ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts new file mode 100644 index 000000000..e957e2a00 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 238 (`db/migrations.ts`). + * + * Document providers, part 3 of 3: the pairing and its sync state. + * `content_sha256` and `pushed_sha256` are two columns on purpose. The + * first is the bytes both sides last agreed on, the second is what TREK + * itself last uploaded. Collapsing them into one is precisely the mistake + * that builds an echo loop: a webhook fires for TREK's own write, the core + * cannot tell it from a stranger's edit, and the file bounces. + * `remote_missing_at` records that something vanished upstream instead of + * acting on it, the rule Dawarich already follows with `source_missing_at`. + * An unmounted share answers with an empty listing, and reading that as + * "everything was deleted" would empty a trip. + * `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is + * permanently deleted in TREK, so the next run does not cheerfully download + * it again. + */ +export class Migration20200101035800_document_providers_part_3_of_3_the extends Migration { + override name = 'Migration20200101035800_document_providers_part_3_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_sync_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL, + trek_doc_uid TEXT NOT NULL, + remote_id TEXT, + remote_name TEXT, + remote_version TEXT, + remote_size INTEGER, + remote_modified_at TEXT, + content_sha256 TEXT, + pushed_sha256 TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error_code TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + remote_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + synced_at TEXT + ) + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote + ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file + ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid + ON document_sync_items(link_id, trek_doc_uid) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts new file mode 100644 index 000000000..a7f21dc09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 239 (`db/migrations.ts`). + * + * Document providers: when TREK itself put a provider copy in the bin. + * + * A file deleted in TREK under the `trash` policy takes its provider copy with + * it. Taken back out of TREK's trash, it has to go up again; a copy somebody + * else deleted in the meantime must not. Both leave the same gap in a listing, + * so the difference is written down when TREK acts rather than guessed at later. + * + * No backfill from the binding's current policy: that policy may not be the one + * the deletion ran under, and reading it back is the retroactive mistake this + * column exists to avoid. A row left NULL is treated like a copy somebody else + * deleted, which flags it instead of uploading it. + */ +export class Migration20200101035900_document_providers_when_trek_itself_put_a extends Migration { + override name = 'Migration20200101035900_document_providers_when_trek_itself_put_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'document_sync_items', 'remote_trashed_at', `remote_trashed_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts new file mode 100644 index 000000000..a71a694e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A trip whose day rows were clipped at the old 365-day limit. */ +interface ClippedTrip { + id: number; + start_date: string; + end_date: string; + span: number; +} + +/** The date `n` days after `start`, as YYYY-MM-DD. */ +function dayAfter(start: string, n: number): string { + const [y, m, d] = start.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10); +} + +/** + * Legacy migration step 240 (`db/migrations.ts`). + * + * Trips longer than a year lost every day past the 365th: generateDays clipped + * the day rows at the old limit while the trip kept its full end date, so the + * last days had a date but nothing to plan on (#2403). The limit is 999 now, and + * this gives the affected trips their missing days. + * + * Only a range whose dated days still run unbroken from the start date is + * extended; a trip that was re-dated by hand or lost a day in the middle is left + * as it is. Dateless days that still hold content stay behind the dated ones, + * where generateDays keeps them. The two-phase renumbering is the same dance + * generateDays does around UNIQUE(trip_id, day_number). + */ +export class Migration20200101040000_trips_longer_than_a_year_lost_every extends Migration { + override name = 'Migration20200101040000_trips_longer_than_a_year_lost_every'; + + override async up(): Promise { + const trips = (await this.execute(` + SELECT id, start_date, end_date, + CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span + FROM trips + WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999 + `)) as ClippedTrip[]; + + for (const trip of trips) { + const rows = (await this.execute(`SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number`, [ + trip.id, + ])) as { id: number; date: string | null }[]; + + const dated = rows.filter((r) => r.date); + if (dated.length >= trip.span) continue; + if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue; + const dateless = rows.filter((r) => !r.date); + + // Phase one: park every row on a negative number so the renumbering below + // cannot collide with UNIQUE(trip_id, day_number). + for (const [i, r] of rows.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [-(i + 1), r.id]); + } + for (const [i, r] of dated.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [i + 1, r.id]); + } + for (let i = dated.length; i < trip.span; i++) { + await this.execute(`INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`, [ + trip.id, + i + 1, + dayAfter(trip.start_date, i), + ]); + } + for (const [i, r] of dateless.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [trip.span + i + 1, r.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts new file mode 100644 index 000000000..384993a6b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 241 (`db/migrations.ts`). + * + * The road-trip day boundaries carried the old trip limit as a CHECK on + * day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the + * table keeps only the floor. SQLite cannot alter a CHECK, hence the + * rebuild; nothing references the table, so the rows are simply copied. + */ +export class Migration20200101040100_the_road_trip_day_boundaries_carried_the extends Migration { + override name = 'Migration20200101040100_the_road_trip_day_boundaries_carried_the'; + + override up(): void { + this.addSql(` + CREATE TABLE roadtrip_day_boundaries_new ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number >= 1), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + + this.addSql(` + INSERT INTO roadtrip_day_boundaries_new + SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries + `); + + this.addSql(`DROP TABLE roadtrip_day_boundaries`); + + this.addSql(`ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries`); + } +} diff --git a/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts b/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts new file mode 100644 index 000000000..57d11840b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Plan 4 Task 8a program carry — `trek_photo_cache_meta.cache_key` was + * declared a bare `TEXT PRIMARY KEY` (Migration20200101014900), which SQLite + * does NOT implicitly make `NOT NULL` the way it does for an `INTEGER + * PRIMARY KEY` rowid alias — a genuine, if surprising, SQLite quirk (3e's R5 + * Task 0 finding). Every statement that writes this column + * (`TrekPhotoCacheMetaRepository.upsertMeta`'s `INSERT OR REPLACE`, its only + * writer) has always bound a real `string`, never `null` — this migration + * tightens the schema to match the code's actual invariant, not a behaviour + * change. SQLite has no `ALTER COLUMN ... SET NOT NULL`, so the column is + * added via the standard rebuild-table recipe (the same shape + * Migration20200101020000 uses in the opposite direction). + */ +export class Migration20200101040200_trek_photo_cache_meta_cache_key_not_null extends Migration { + override name = 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'; + + override up(): void { + this.addSql(` + CREATE TABLE trek_photo_cache_meta_new ( + cache_key TEXT PRIMARY KEY NOT NULL, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ) + `); + + this.addSql(` + INSERT INTO trek_photo_cache_meta_new + SELECT cache_key, content_type, fetched_at + FROM trek_photo_cache_meta + WHERE cache_key IS NOT NULL + `); + + this.addSql(`DROP TABLE trek_photo_cache_meta`); + + this.addSql(`ALTER TABLE trek_photo_cache_meta_new RENAME TO trek_photo_cache_meta`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts b/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts new file mode 100644 index 000000000..bac62e0f7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; +import type { SqliteConnection } from '@mikro-orm/sqlite'; +import { reseatBookedNights } from '../reseat-booked-nights'; + +/** + * Legacy migration step 242 (`db/migrations.ts`). + * + * Seat every booked night where its check-in says, the way a night booked + * today is seated (`night-seat.ts`'s `seatAmong`, which + * `AccommodationsService` applies) — see `reseat-booked-nights.ts`'s own + * header for the full rule. Every trip planned before that ordering change + * keeps the old, wrong order until somebody edits its check-in; this brings + * them forward the same way step 229/230 (`Migration20200101034900`) brought + * the day stop itself forward for older bookings. + * + * Plan 4 Task 5c's finding: unlike ruling 7's assumption, nothing in the + * numbered chain ever called `reseatBookedNights` — the two migrations near + * the end that touch booked nights both call `attachStayStopsToCheckInDay` + * instead (a different legacy step). This is the numbered migration that was + * missing; an install upgrading through the chain without it never got this + * one-time reorder. + * + * Frozen, called rather than reimplemented (ruling 7's spirit: don't rewrite + * a step onto the ORM's query builder, since that risks producing a + * different result than the one already shipped). `reseatBookedNights` wants + * the synchronous better-sqlite3 API directly, not `this.execute()`, so this + * reaches it through `getNativeClient()` — the same physical connection/ + * transaction `this.addSql()` would use, so it still runs inside the + * migration's transaction exactly as the function's own docstring assumes. + */ +export class Migration20200101040300_a_booked_night_finally_took_the_seat_a_new extends Migration { + override name = 'Migration20200101040300_a_booked_night_finally_took_the_seat_a_new'; + + override async up(): Promise { + const connection = this.driver.getConnection() as SqliteConnection; + const db = await connection.getNativeClient(); + const seated = reseatBookedNights(db); + if (seated > 0) console.log(`[DB] Reseated ${seated} booked night(s) to their check-in day`); + } +} diff --git a/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts b/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts new file mode 100644 index 000000000..d402552ae --- /dev/null +++ b/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 243 (`db/migrations.ts`). + * + * Immich learns the switch Synology, AirTrail and Dawarich already have: a + * server behind a self-signed certificate can be trusted per user (#2475). + * Off by default, and only 1 counts as on. + * + * The two settings rows are the toggle for it and the auto-upload toggle + * step 113 (`Migration20200101015300`) meant to add. That one only ran + * where the Immich provider row already existed, which on a fresh install it + * never did (the seeders run after the migrations), so fresh installs never + * showed the upload toggle. Both rows are in `PhotoProviderSeeder` as well for + * the same reason; here they reach the installs that already have the provider + * row. Re-runnable. + */ +export class Migration20200101040400_immich_learns_the_switch_synology_airtrail_and extends Migration { + override name = 'Migration20200101040400_immich_learns_the_switch_synology_airtrail_and'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'users', + 'immich_allow_insecure_tls', + `immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0`, + ); + const immich = await this.execute(`SELECT 1 AS present FROM photo_providers WHERE id = 'immich' LIMIT 1`); + if (immich.length === 0) return; + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2), + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts b/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts new file mode 100644 index 000000000..dab03538b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts @@ -0,0 +1,32 @@ +import { createPlaceRegionsFollowPlaceTrigger } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 244 (`db/migrations.ts`). + * + * A place that moves takes its Atlas country with it (#2527). + * + * place_regions caches the country and region Atlas resolved from a place's + * coordinates and address, and nothing re-derives a row that is already there. + * Correcting a place's location left the old row in charge, so Atlas, the + * dashboard stats and the journey stats kept counting the country the place had + * just left. + * + * A trigger rather than a delete in the places code, because the row is derived + * from the place and every writer of lat, lng or address has to let go of it: + * the place editor, update_place, the plugin RPC, the import enrichment and its + * address backfill, and whatever comes next. The WHEN clause matters because + * every place edit writes lat, lng and address back whether they changed or + * not, and renaming a place must not throw away a good row. The next Atlas load + * resolves the place where it is now. + * + * This is 244 on main (4.3.3) as well, so it sits here, ahead of Web Push. + * Step 246 (`Migration20200101040700`) creates the same trigger once more. + */ +export class Migration20200101040500_a_place_that_moves_takes_its_atlas extends Migration { + override name = 'Migration20200101040500_a_place_that_moves_takes_its_atlas'; + + override async up(): Promise { + await createPlaceRegionsFollowPlaceTrigger(this); + } +} diff --git a/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts b/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts new file mode 100644 index 000000000..2d1fd672b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 245 (`db/migrations.ts`). + * + * Web Push (#894): one row per browser a user switched push on in. + * + * The endpoint is the push service URL that browser handed out, so it is + * unique: a browser shared by two accounts belongs to whoever subscribed on it + * last. p256dh and auth are the browser's keys for the RFC 8291 message + * encryption. vapid_public_key is the server key the subscription was made + * against, so a changed key pair shows up as a mismatch the sender can clean + * up, rather than as a push service refusing every message. failure_count and + * last_success_at are bookkeeping for the sender; the rows go with the user. + * + * Web Push was 244 before the #2527 trigger took that slot to match main. A + * 4.3.3 install is already at 244 with the trigger and gets the table here; an + * instance that ran this at 244 replays it as a no-op. + */ +export class Migration20200101040600_web_push_one_row_per_browser_a extends Migration { + override name = 'Migration20200101040600_web_push_one_row_per_browser_a'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + vapid_public_key TEXT NOT NULL, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_success_at TEXT, + failure_count INTEGER NOT NULL DEFAULT 0 + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts b/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts new file mode 100644 index 000000000..2eb177264 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts @@ -0,0 +1,18 @@ +import { createPlaceRegionsFollowPlaceTrigger } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 246 (`db/migrations.ts`). + * + * The #2527 trigger once more (step 244, `Migration20200101040500`), for an + * instance that ran Web Push at 244 before the trigger took that slot. Such an + * instance never runs 244 again, so it gets the trigger here. Everywhere else it + * already exists and this is a no-op. + */ +export class Migration20200101040700_the_2527_trigger_once_more_for_an extends Migration { + override name = 'Migration20200101040700_the_2527_trigger_once_more_for_an'; + + override async up(): Promise { + await createPlaceRegionsFollowPlaceTrigger(this); + } +} diff --git a/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts b/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts new file mode 100644 index 000000000..e15201ec9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 247 (`db/migrations.ts`). + * + * A stop kept on the day but left out of its route (#2532): shown as a pin, not + * driven to. 0 routes it like every stop before this existed. + */ +export class Migration20200101040800_a_stop_kept_on_the_day_but extends Migration { + override name = 'Migration20200101040800_a_stop_kept_on_the_day_but'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'route_excluded', `route_excluded INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts b/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts new file mode 100644 index 000000000..30a49a271 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 248 (`db/migrations.ts`). + * + * How many of an item are packed so far (#2296), for "7 of 10 shirts". NULL is + * the plain checkbox every item has had, and `checked` stays the answer to + * "done?". + */ +export class Migration20200101040900_how_many_of_an_item_are_packed extends Migration { + override name = 'Migration20200101040900_how_many_of_an_item_are_packed'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'packed_quantity', `packed_quantity INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts b/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts new file mode 100644 index 000000000..6a03f9aca --- /dev/null +++ b/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 249 (`db/migrations.ts`). + * + * A bucket-list wish for one state or province rather than the whole country + * (#1901). NULL keeps every existing wish a country wish. + */ +export class Migration20200101041000_a_bucket_list_wish_for_one_state extends Migration { + override name = 'Migration20200101041000_a_bucket_list_wish_for_one_state'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'region_code', `region_code TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts b/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts new file mode 100644 index 000000000..66564df31 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 250 (`db/migrations.ts`). + * + * Half company holidays (#2439): 0.5 covers the morning or afternoon and leaves + * the other half open for a half vacation day. 1 is what every holiday was. + */ +export class Migration20200101041100_half_company_holidays_0_5_covers_the extends Migration { + override name = 'Migration20200101041100_half_company_holidays_0_5_covers_the'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_company_holidays', 'fraction', `fraction REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts b/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts new file mode 100644 index 000000000..86cf6ae75 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 251 (`db/migrations.ts`). + * + * A journey entry still being written (#696): contributors see it, the public + * share page does not. Its own flag, since `visibility` defaults to 'private' on + * every entry and filtering on it would empty every shared journey. + */ +export class Migration20200101041200_a_journey_entry_still_being_written extends Migration { + override name = 'Migration20200101041200_a_journey_entry_still_being_written'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'is_draft', `is_draft INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts b/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts new file mode 100644 index 000000000..ac80d740c --- /dev/null +++ b/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 252 (`db/migrations.ts`). + * + * Two narrower ways to share a trip (#1712): only the travel (flights, trains, + * stays) without the day's activities, and without the place photos. Both off + * keeps every existing link exactly as it was. + */ +export class Migration20200101041300_two_narrower_ways_to_share_a_trip extends Migration { + override name = 'Migration20200101041300_two_narrower_ways_to_share_a_trip'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'share_travel_only', `share_travel_only INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing(this, 'share_tokens', 'share_hide_images', `share_hide_images INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts b/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts new file mode 100644 index 000000000..a03175ae5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 253 (`db/migrations.ts`). + * + * A note on a settle-up payment (#2340), as expenses already have one: "paid + * back in cash at the airport". Null on every existing payment. + */ +export class Migration20200101041400_a_note_on_a_settle_up_payment extends Migration { + override name = 'Migration20200101041400_a_note_on_a_settle_up_payment'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'note', `note TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts b/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts new file mode 100644 index 000000000..28bf008ab --- /dev/null +++ b/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 254 (`db/migrations.ts`). + * + * Google API calls per UTC day (#1582), so the admin's daily ceiling has + * something to count against. One row a day, nothing about who searched. + */ +export class Migration20200101041500_google_api_calls_per_utc_day extends Migration { + override name = 'Migration20200101041500_google_api_calls_per_utc_day'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts b/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts new file mode 100644 index 000000000..b82ecf561 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 255 (`db/migrations.ts`). + * + * Packing templates remember what an item weighs, how many of it go along and + * which bag it lives in (#1131), so applying one does not mean typing all of + * that in again. The bag is kept by name: bags belong to a trip. + */ +export class Migration20200101041600_packing_templates_remember_what_an_item_weighs extends Migration { + override name = 'Migration20200101041600_packing_templates_remember_what_an_item_weighs'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_template_items', 'weight_grams', `weight_grams INTEGER`); + await addColumnIfMissing(this, 'packing_template_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`); + await addColumnIfMissing(this, 'packing_template_items', 'bag_name', `bag_name TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts b/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts new file mode 100644 index 000000000..61b04b718 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 256 (`db/migrations.ts`). + * + * A journey's state as its owner sets it (#762): draft, live or completed. NULL + * keeps the state derived from the linked trips' dates, which is what every + * journey had so far; a journey with no trip could only ever be a draft. + */ +export class Migration20200101041700_a_journey_s_state_as_its_owner extends Migration { + override name = 'Migration20200101041700_a_journey_s_state_as_its_owner'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'status_override', `status_override TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts b/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts new file mode 100644 index 000000000..a2d8f376b --- /dev/null +++ b/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 257 (`db/migrations.ts`). + * + * A place's e-mail and its own opening hours, typed in by hand (#2472): the + * search fills in what it knows, and these are for everything it does not. + */ +export class Migration20200101041800_a_place_s_e_mail_and_its extends Migration { + override name = 'Migration20200101041800_a_place_s_e_mail_and_its'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'email', `email TEXT`); + await addColumnIfMissing(this, 'places', 'opening_hours', `opening_hours TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts b/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts new file mode 100644 index 000000000..372588c51 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 258 (`db/migrations.ts`). + * + * A journey that puts an entry on the map where its first photo was taken + * (#1003). Off unless the owner turns it on: an entry somebody placed by hand is + * never moved, but an entry left without a place on purpose should not grow one + * either just because a picture was added. + */ +export class Migration20200101041900_a_journey_that_puts_an_entry_on extends Migration { + override name = 'Migration20200101041900_a_journey_that_puts_an_entry_on'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'photo_location', `photo_location INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/nul-safe-sqlite-platform.ts b/server/src/db/nul-safe-sqlite-platform.ts new file mode 100644 index 000000000..bdc080ab4 --- /dev/null +++ b/server/src/db/nul-safe-sqlite-platform.ts @@ -0,0 +1,109 @@ +import { SqlitePlatform } from '@mikro-orm/sql'; + +/** + * Program rule 22 (`docs/superpowers/plans/2026-09-21-orm-migration-program.md`), + * surfaced by Plan 3c Task 4 review M1: MikroORM 7 never sends the driver a + * parameterised statement. `AbstractSqlConnection.execute` calls + * `platform.formatQuery(sql, params)` (`@mikro-orm/core`'s `Platform.formatQuery`) + * and hands better-sqlite3 the fully INLINED SQL text — `formatQuery` walks the + * `?` placeholders and splices in `this.quoteValue(param)` for each one, so + * every bound value this ORM ever writes is a literal in the statement string, + * not a bound parameter the driver escapes separately. `SqlitePlatform`'s own + * `quoteValue` (`@mikro-orm/sql`'s `AbstractSqlPlatform.quoteValue`, for + * anything that isn't a `Date`) delegates to `escape`, which wraps a string in + * single quotes and doubles embedded `'` — the standard SQL literal shape. + * + * A `\u0000` inside that string survives the JS-level quoting untouched (it + * is not `'`, so nothing escapes it), but the C string this literal eventually + * becomes IS NUL-terminated: SQLite's own tokenizer reads the statement text + * as a NUL-terminated buffer, so it stops scanning at the embedded `\0` — + * inside the still-open `'…'` literal, which is exactly why the failure mode + * is always "unterminated string", never anything an attacker could turn into + * an injection (verified in the Task 4 review: every NUL statement in this + * class fails closed with a syntax error, 500, never a different query). + * + * The legacy raw-SQL layer never had this problem: `better-sqlite3` binds `?` + * parameters as real, separately-transmitted values (no inlining, no NUL + * truncation), so a NUL byte in a user string round-tripped byte-for-byte — + * `GET /api/trips/:id/places?search=%00` was 200 and a place named + * `"nul\u0000name"` created (201) with its name intact. Once a route's + * statement moves off `DatabaseService`'s raw bind onto anything this ORM + * inlines (a QB read, `em.insert`, `nativeUpdate` — every one of them, + * regardless of domain), that same input 500s instead, byte-for-byte across + * every domain: this is corollary to rule 22, not specific to any one plan. + * + * The fix restores the round-trip AT THE PLATFORM, once, program-wide: a + * string containing `\u0000` renders not as one `'…'` literal but as a + * parenthesised `||` (SQL string concatenation) chain, splicing in SQLite's + * builtin `char(0)` — which returns a one-character string holding the NUL + * codepoint — everywhere the original string held one. `char()` is a core + * SQLite function (no extension, no `PRAGMA`), and `||` binds tighter than + * every arithmetic/comparison/logical operator that could otherwise appear + * around a bound value, so `(...)`'s parens are redundant for a scalar + * comparison — kept anyway so this expression composes safely as an + * IN-list element or the operand of anything else `escape()`'s caller + * splices it into (`AbstractSqlPlatform.escape` recurses through arrays + * with `value.map(v => this.escape(v)).join(', ')`, so a NUL inside one + * array element must stay a self-contained expression). Splitting on + * `\u0000` and re-quoting each piece with the platform's own `escape` (via + * `super.escape`) reuses the exact same `'`-doubling every non-NUL string + * already goes through, so a NUL next to a quote or a backslash (neither + * of which SQLite string literals treat specially) round-trips exactly as + * it did before this override existed. + * + * Deliberately scoped to `typeof value === 'string'` for the NUL half above: + * that IS the M1 fix, not a general re-implementation of `escape`/ + * `quoteValue`. + * + * **Rule 22 extension (Plan 3c Task 9, close-out review A H1):** non-finite + * numbers hit the SAME inlining mechanism, and rule 21's `toRowId` guards do + * NOT fence every caller off from it — `toRowId` is what a CONVERTED + * service calls to validate an id BEFORE it reaches a repository, but two + * call sites (`PlacesService.verifyTripAccess`, `RealtimeGateway.handleJoin`) + * still did `Number(untrusted)` with no finite check and handed the result + * straight to `TripsRepository.findAccessible`'s raw `andWhere('t.id = ?', + * [trip_id])` bind. Traced against the installed `@mikro-orm/sql` 7.2.1 + * source: `AbstractSqlPlatform.quoteValue` (`sql/AbstractSqlPlatform.js:94`) + * special-cases only a raw fragment and a plain object (JSON-stringified); + * everything else — including every `number` — falls to `this.escape(value)`. + * `SqlitePlatform.escape` (`sql/dialects/sqlite/SqlitePlatform.js:116-133`) + * DOES special-case numbers, but not usefully: `typeof value === 'number' || + * typeof value === 'bigint'` returns `'' + value` — plain string + * concatenation, which for a non-finite value gives `'NaN'`, `'Infinity'` or + * `'-Infinity'`, an UNQUOTED bareword spliced straight into the SQL text + * (finite numbers render as an ordinary numeric literal this way and are + * fine; only the three non-finite values break). SQLite's parser reads that + * bareword as a column reference (`no such column: NaN`/`Infinity`) and + * throws, a 500 where the legacy raw bind never had the problem: + * `better-sqlite3` accepts a JS `number` directly and binds it as SQLite's + * own `REAL`/`NULL` value, never as text — verified empirically against the + * installed driver (not assumed): `db.prepare('select typeof(?)').get(NaN)` + * reports `'null'` (better-sqlite3 refuses `NaN` as a bindable REAL and + * silently binds SQL `NULL` instead), while `db.prepare('select + * typeof(?)').get(Infinity)` reports `'real'` and round-trips as the REAL + * value `Infinity` — SQLite itself has no `Infinity` literal, but its + * `REAL` parser saturates an out-of-range exponent to positive/negative + * infinity (double-precision overflow), and `9e999`/`-9e999` verified to + * produce that exact value (`select 9e999` → `Infinity`), so it is the + * literal spelling that reproduces the driver's own bound value byte-for- + * byte, not an approximation. Overriding `escape` (not `quoteValue`, which + * only special-cases a raw fragment/plain object before delegating here + * anyway) catches every number this platform ever renders, program-wide, + * the same fail-safe shape as the NUL fix above — rule 21's `toRowId` + * guards remain the PRIMARY defence (a validated id never reaches this + * branch), and this is the platform's own backstop for the callers that do + * not yet guard. + */ +export class NulSafeSqlitePlatform extends SqlitePlatform { + override escape(value: unknown): string { + if (typeof value === 'string' && value.includes('\u0000')) { + const literals = value.split('\u0000').map(part => super.escape(part)); + return `(${literals.join(' || char(0) || ')})`; + } + if (typeof value === 'number' && !Number.isFinite(value)) { + if (Number.isNaN(value)) return 'NULL'; + return value > 0 ? '9e999' : '-9e999'; + } + return super.escape(value); + } +} diff --git a/server/src/db/orm-driver.ts b/server/src/db/orm-driver.ts new file mode 100644 index 000000000..7dcda9f7c --- /dev/null +++ b/server/src/db/orm-driver.ts @@ -0,0 +1,101 @@ +import { getRawConnection } from './database'; +import type { Configuration } from '@mikro-orm/core'; +import type { SqlitePlatform } from '@mikro-orm/sql'; +import { SqliteConnection, SqliteDriver } from '@mikro-orm/sqlite'; +import { NulSafeSqlitePlatform } from './nul-safe-sqlite-platform'; + +import type Database from 'better-sqlite3'; +import { SqliteDialect, SqliteDriver as KyselySqliteDriver, type Dialect, type SqliteDialectConfig } from 'kysely'; + +/** + * The stock Kysely SQLite driver's `destroy()` unconditionally does + * `db.close()`. MikroORM's own `close()` (`AbstractSqlConnection.close()`) + * always calls the Kysely client's `destroy()` too, regardless of the `force` + * flag it was given, and it caches that client behind a private field — so a + * `SqliteConnection` subclass has no hook to intercept just that one call. + * The fix has to live at the Kysely layer: hand MikroORM a driver identical to + * the stock one except `destroy()` is a no-op, so closing the ORM never closes + * a handle somebody else owns. + */ +class NonClosingSqliteDriver extends KyselySqliteDriver { + override async destroy(): Promise { + // The handle's owner (whoever `getHandle` below reads from) closes it. + } +} + +/** + * A SQLite driver bound to a better-sqlite3 handle somebody else owns. + * + * Forced by the test harness: `database.ts` hands each vitest worker + * `:memory:`, and in better-sqlite3 an in-memory database belongs to its + * *connection* — two connections to `:memory:` are two unrelated databases. + * MikroORM's stock `SqliteConnection.createKyselyDialect()` always does + * `new Database(dbName)`, so it would migrate a database nothing else in the + * process can see. Sharing the handle also keeps the single-writer property the + * app has always had: one handle, one WAL. + * + * `getHandle` is called on every (re)connect rather than once: a backup restore + * closes the handle and opens a new one, and Kysely caches whatever it is given + * for the life of its client, so `reinitialize()` closes the connection and the + * next `connect()` lands here again with the new handle. + */ +export function createBoundSqliteDriver(getHandle: () => Database.Database): typeof SqliteDriver { + class BoundSqliteConnection extends SqliteConnection { + override createKyselyDialect(): Dialect { + const handle = getHandle(); + const config: SqliteDialectConfig = { database: handle }; + // `SqliteConnection.getNativeClient()` reads this private field, but only + // ever sets it from the base class's own `createKyselyDialect()` — which + // this override replaces entirely, so it never runs. Capturing it here + // too is what lets a migration reach this exact synchronous + // better-sqlite3 handle via `getNativeClient()` in production, not just + // under the unbound test driver (`createMigrationOrm()` in + // tests/helpers/migration-step.ts, whose stock SqliteConnection sets it + // unmodified). Needed by migrations that call a frozen, hand-written + // step directly instead of through `this.execute()` — e.g. + // Migration20200101040300, which calls reseat-booked-nights.ts. + (this as unknown as { database: Database.Database }).database = handle; + // Delegate the compiler/adapter/introspector to a throwaway stock dialect + // (stateless factories); only `createDriver()` needs the non-closing swap. + const stock = new SqliteDialect(config); + return { + createDriver: () => new NonClosingSqliteDriver(config), + createQueryCompiler: () => stock.createQueryCompiler(), + createAdapter: () => stock.createAdapter(), + createIntrospector: (db) => stock.createIntrospector(db), + }; + } + } + + return class BoundSqliteDriver extends SqliteDriver { + constructor(config: Configuration) { + super(config); + // The base constructor already built a stock SqliteConnection; replace it + // before anything connects. `connection` is protected on AbstractSqlDriver + // and SqliteDriver's constructor hardcodes the class, so there is no + // supported hook for supplying it. + (this as unknown as { connection: SqliteConnection }).connection = new BoundSqliteConnection(config); + // Program rule 22 (NulSafeSqlitePlatform's own docstring): `SqliteDriver`'s + // constructor hardcodes `new SqlitePlatform()` too, with no config hook to + // supply a different one. `DatabaseDriver.platform` is a plain, reassignable + // field — `DatabaseDriver.setMetadata()` (called once, during + // `MikroORM.init()`, always after this constructor returns) is what actually + // propagates it to the connection via `connection.setPlatform(this.platform)`, + // so replacing it here, synchronously, is in time for every later read. + (this as unknown as { platform: SqlitePlatform }).platform = new NulSafeSqlitePlatform(); + } + }; +} + +/** + * The production driver: bound to the connection `db/database.ts` owns. + * + * Wrapped in a closure rather than passed as `getRawConnection` directly: + * `createBoundSqliteDriver` doesn't call its argument until something actually + * connects, but merely *reading* the `getRawConnection` binding here would — + * this module is evaluated at import time, and a test that mocks + * `db/database` with a partial replacement (most do, via `buildDbMock` or a + * hand-rolled object) throws on that read even when the test never touches + * the database at all. + */ +export const SharedSqliteDriver = createBoundSqliteDriver(() => getRawConnection()); diff --git a/server/src/db/orm.ts b/server/src/db/orm.ts new file mode 100644 index 000000000..d744febce --- /dev/null +++ b/server/src/db/orm.ts @@ -0,0 +1,104 @@ +import mikroOrmConfig from '../mikro-orm.config'; +import { registerReinitializeHook, runDemoSeed } from './database'; +import { migrateToHead } from './legacy-baseline'; +import { withRequestContext } from '../nest/database/request-context'; +import type { AnyEntity, EntityClass, EntityManager, EntitySchema, IDatabaseDriver, MikroORM } from '@mikro-orm/core'; +import type { Migrator } from '@mikro-orm/migrations'; +import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite'; + +/** + * Schema ownership at startup. + * + * This replaces `createTables()` → `runMigrations()` → `runSeeds()`, which used + * to run synchronously inside `database.ts::initDb()` at module load. The + * migrator is async, so the work moved to `buildApp()`, which is the first place + * in the boot with an await and still runs before anything reads the database + * (`bootstrap.ts` resolves SettingsService right after). + */ + +/** + * Whatever MikroORM instance this process holds. + * + * Widened on the entity list because Nest's `MikroORM` token resolves to the + * class's own `readonly` form while a direct `init()` yields the mutable one; + * nothing here reads that parameter. + */ +type AnyOrm = MikroORM< + IDatabaseDriver, + EntityManager, + readonly (string | EntityClass | EntitySchema)[] +>; + +/** Migrate to head, then seed. Safe to call again — both halves are idempotent. */ +export async function runSchemaBootstrap(orm: AnyOrm): Promise { + const migrator = orm.config.getExtension('@mikro-orm/migrator') as Migrator; + // A database the retired positional runner migrated has no migrator rows yet; + // what it already has is recorded in the same transaction as the run, or the + // run would replay the whole history over it (see legacy-baseline.ts). Every + // other database just gets its pending migrations. + await migrateToHead(orm.em.getConnection(), migrator); + + const defaultSeeder = orm.config.get('seeder').defaultSeeder; + if (defaultSeeder) await orm.seeder.seedString(defaultSeeder); + + // Demo data depends on the seeded categories (hard-coded ids under an FK), so + // it can only run once the seeders above have. + // + // Plan 3c Task 0b (R9): wrapping the call HERE, the one place + // `runSchemaBootstrap` already has `orm` in scope, covers BOTH of + // `runDemoSeed`'s real call sites at once (`bootstrap.ts`'s initial boot + // and `attachOrm`'s restore hook below, both of which only ever reach + // `runDemoSeed` through this function) with no second wrapper to keep in + // sync. + // + // Plan 3i Task 3: `runDemoSeed` (and `seedDemoData` beneath it, converted + // onto `DemoRepository`) is `async` now — this call is awaited so a + // rejection surfaces here rather than becoming an unhandled promise + // rejection, and so the seed genuinely completes before + // `runSchemaBootstrap` (and therefore `buildApp()`/this hook's own + // restore path) returns to its caller, the same completion guarantee the + // fully-synchronous version had by construction. + await withRequestContext(orm, () => runDemoSeed()); +} + +/** + * Binds the ORM to this process's connection lifecycle. + * + * A restore swaps the SQLite file and reopens the raw handle; Kysely caches + * whatever handle it was given, so without this the ORM would keep talking to a + * closed connection. Closing and reconnecting sends the driver back through + * `createKyselyDialect()`, which picks up the new handle — and the restored file + * may be an older backup, so it gets migrated forward too. + */ +export function attachOrm(orm: AnyOrm): void { + registerReinitializeHook(async () => { + const connection = orm.em.getConnection(); + // The handle is owned and closed by `database.ts`; the bound driver's + // `destroy()` is a no-op (see `orm-driver.ts`), so this close never reaches it. + await connection.close(true); + await connection.connect(); + await runSchemaBootstrap(orm); + }); +} + +/** + * An ORM instance outside Nest, for the vitest global setup that builds the + * schema snapshot. Production uses the container-owned one from + * `MikroOrmModule.forRoot()` instead of creating a second. + */ +export function createStandaloneOrm(): Promise { + return SqliteMikroORM.init({ + ...mikroOrmConfig, + // Entity discovery is skipped deliberately. Migrations and seeders are raw + // SQL and need no metadata, and the global setup that calls this runs as ESM + // in vitest's main process, where discovering `*.entity.ts` fails on Node's + // extensionless import resolution. + entities: [], + entitiesTs: [], + discovery: { warnWhenNoEntities: false }, + // The migrator's per-migration progress log is production boot output; the + // global setup already reports the snapshot in one line of its own, and a + // failing migration still throws. + migrations: { ...mikroOrmConfig.migrations, silent: true }, + }); +} diff --git a/server/src/db/repositories/Addons.repository.ts b/server/src/db/repositories/Addons.repository.ts new file mode 100644 index 000000000..ff490979f --- /dev/null +++ b/server/src/db/repositories/Addons.repository.ts @@ -0,0 +1,93 @@ +import type { Addons, AddonConfig } from '../entities/Addons.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** An `addons` row as the API emits it. */ +export interface AddonRow { + id: string | null; + name: string; + description: string | null; + type: string; + icon: string | null; + enabled: boolean | null; + config: AddonConfig | null; + sort_order: number | null; +} + +const _addonRowKeys: AssertRowKeys = true; + +export class AddonsRepository extends TrekRepository { + /** + * `SELECT enabled FROM addons WHERE id = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): a + * throwaway forked context always sees a write on the same id inside the + * same request (`admin.service.ts`'s still-raw `UPDATE addons SET enabled + * = ?` on the same connection), and the entity never lands in the + * request's identity map. + */ + async isEnabled(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['enabled'] }); + return !!row?.enabled; + } + + /** + * `SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 + * ORDER BY sort_order` — this reads every scalar column (not just the + * legacy statement's five), because `AddonsService.list()` already + * explicitly picks the five fields it forwards to the client; narrowing the + * query itself would need a second, `AssertRowKeys`-incompatible row type + * for no parity benefit. + */ + async listEnabled(): Promise { + const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } }); + return rows.map((row) => toRow(row) as AddonRow); + } + + // --------------------------------------------------------------------- + // Plan 3i Task 1 (`AdminService` — AD26/AD30/AD33/AD36/AD39, admin's + // addon-shelf listing + config/enabled writes). + // --------------------------------------------------------------------- + + /** + * AD26 (`admin.service.ts#listAddons`) — `SELECT * FROM addons ORDER BY + * sort_order, id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE + * enabled = 1`) — admin's own catalog listing, filtered afterward in JS + * for managed-instance hiding. + */ + async listAllOrdered(): Promise { + const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } }); + return rows.map((row) => toRow(row) as AddonRow); + } + + /** AD30/AD39 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM addons WHERE id = ?`. */ + async findById(id: string): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as AddonRow) : null; + } + + /** AD33 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE addons SET enabled = ? WHERE id = ?`. */ + async setEnabled(id: string, enabled: boolean): Promise { + await this.nativeUpdate({ id }, { enabled: !!enabled }); + } + + /** + * AD36 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — + * `UPDATE addons SET config = ? WHERE id = ?`. The LLM-parsing addon's API + * key is already encrypted by `AdminService` + * (`prepareLlmAddonConfigForWrite`) before this call — this repository + * never touches the crypto, only persists whatever JSON blob it is + * handed (3e R6's encrypted-column precedent: the service encrypts, the + * repository passes the ciphertext through untouched). `config` is a + * `p.json()` column — MikroORM serializes the object to the stored TEXT + * column itself, the same JSON text the legacy statement's hand-called + * `JSON.stringify` produced. + */ + async setConfig(id: string, config: AddonConfig): Promise { + await this.nativeUpdate({ id }, { config }); + } +} diff --git a/server/src/db/repositories/AppSettings.repository.ts b/server/src/db/repositories/AppSettings.repository.ts new file mode 100644 index 000000000..025efd893 --- /dev/null +++ b/server/src/db/repositories/AppSettings.repository.ts @@ -0,0 +1,124 @@ +import type { AppSettings } from '../entities/AppSettings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** An `app_settings` row as the API emits it. */ +export interface AppSettingsRow { + key: string | null; + value: string | null; +} + +const _appSettingsRowKeys: AssertRowKeys = true; + +export class AppSettingsRepository extends TrekRepository { + /** + * `SELECT value FROM app_settings WHERE key = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring for the full mechanism and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): the read + * is answered from a throwaway forked context that is cleared afterwards, + * so a raw/native write or `deleteValue` on the same key inside the same + * request is always seen, and the entity never lands in the request's + * identity map to become a stale pending change at a later `flush()`. + */ + async getValue(key: string): Promise { + const row = await this.findOne({ key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** + * `SELECT key, value FROM app_settings WHERE key IN (?, ?, ...)` — a + * NULL-valued row is silently dropped from the returned Map, matching the + * legacy callers, which never distinguished "row present with a NULL + * value" from "no row" (both read as absent). + */ + async getValues(keys: string[]): Promise> { + const rows = await this.find({ key: { $in: keys } }); + const values = new Map(); + for (const row of rows) { + if (row.key != null && row.value != null) values.set(row.key, row.value); + } + return values; + } + + /** + * Upsert ruling: `app_settings` (`Migration20200101000000_baseline_schema.ts`) + * is `CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT)` and no + * later migration adds a column or a timestamp — the table has never carried + * anything beyond `key`/`value`. Every legacy call site across settings, + * addons and permissions is either `INSERT OR REPLACE INTO app_settings + * (key, value) VALUES (?, ?)` or `INSERT INTO app_settings (key, value) + * VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` — + * with no other column whose reset by REPLACE could be observable, both + * spellings are exactly `em.upsert`'s job, with the sole unique field + * (`key`, the primary key) as the conflict target. + * + * The one difference from `INSERT OR REPLACE` — verified, unobservable + * here — is the row's implicit SQLite rowid: REPLACE deletes and + * reinserts (rowid moves), `em.upsert`'s `ON CONFLICT ... DO UPDATE` + * keeps the original row (rowid stays). `app_settings` has no `INTEGER + * PRIMARY KEY`, no `AUTOINCREMENT`, no trigger and no foreign key + * referencing it, so nothing here reads the rowid — but the next table + * converted off `INSERT OR REPLACE` that does have one of those needs its + * own check, not an assumption from this comment. + */ + async setValue(key: string, value: string): Promise { + await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + + /** + * `SELECT key, value FROM app_settings WHERE key LIKE ?` — the caller + * passes the bare prefix; this appends the trailing `%` itself, matching + * every legacy call site (`'perm_%'`, `'default_user_setting_%'`). + * + * Escaping: neither the legacy `LIKE 'prefix%'` literals nor `$like`'s + * parameter binding here escape `%`/`_` in the prefix — both are plain + * SQL LIKE, where a bound parameter's `%`/`_` are wildcards exactly like a + * literal's are. The repository test proves the two behave identically for + * a prefix that itself contains `%`/`_`. + */ + async findByKeyPrefix(prefix: string): Promise { + const rows = await this.find({ key: { $like: `${prefix}%` } }); + return rows.map((row) => toRow(row) as AppSettingsRow); + } + + /** `DELETE FROM app_settings WHERE key = ?` */ + async deleteValue(key: string): Promise { + return this.nativeDelete({ key }); + } + + /** + * `SELECT COUNT(*) AS n FROM app_settings WHERE key IN (?, ?, ...)` (Plan + * 3i, storage's SR1 — `StorageRegistryService#seedFromFileOnce`'s + * seed-once boot guard). Counts every ROW whose key matches, including one + * whose `value` is NULL — unlike `getValues()`, which drops a NULL-valued + * row from its Map, this mirrors the legacy statement exactly: no WHERE on + * `value`, so a present-but-NULL row still counts as "already seeded." + */ + async countKeysPresent(keys: string[]): Promise { + return this.count({ key: { $in: keys } }); + } + + /** + * `INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)` (Plan + * 3i, storage's SS1 — `StorageStatsService#scan`). The SAME "one row per + * key" write as `setValue()`'s `ON CONFLICT(key) DO UPDATE` upsert, but a + * genuinely DIFFERENT legacy SQL text (R4/R5's ruling: `app_settings` has + * two distinct upsert dialects across this program's converted call + * sites — preserve both as separately-named methods, never merged into one + * with a boolean/dialect flag, even where — as here — their observable + * behavior on this table is identical). Per `setValue()`'s own docstring, + * the only real difference between the two dialects (REPLACE deletes and + * reinserts, moving the SQLite rowid; `ON CONFLICT ... DO UPDATE` keeps the + * original row) is unobservable on `app_settings` (no `INTEGER PRIMARY + * KEY`/`AUTOINCREMENT`/trigger/FK references it) — this method exists so + * the repository's public surface keeps naming the legacy text 1:1, not + * because the two need different runtime behavior on this table. + */ + async upsertOrReplace(key: string, value: string): Promise { + await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } +} diff --git a/server/src/db/repositories/AssignmentParticipants.repository.ts b/server/src/db/repositories/AssignmentParticipants.repository.ts new file mode 100644 index 000000000..983cdd695 --- /dev/null +++ b/server/src/db/repositories/AssignmentParticipants.repository.ts @@ -0,0 +1,162 @@ +import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity'; +import { coalesce, columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** One assignment's participant, from `QueryHelpersService.loadParticipantsByAssignmentIds` (QH3). */ +export interface ParticipantForAssignmentRow { + assignment_id: number; + user_id: number; + username: string; + avatar: string | null; +} + +/** + * One assignment's participant, from AS2/AS15/AS31 + * (`AssignmentsService.getAssignmentWithPlace`/`getParticipants`/ + * `setParticipants`) — narrower than `ParticipantForAssignmentRow` above + * (no `assignment_id` — the legacy statement never selects it) and with + * `COALESCE(display_name, username)` folded into `username`. + */ +export interface ParticipantRow { + user_id: number; + username: string; + avatar: string | null; +} + +export class AssignmentParticipantsRepository extends TrekRepository { + /** + * `SELECT ap.assignment_id, ap.user_id, u.username, u.avatar + * FROM assignment_participants ap JOIN users u ON ap.user_id = u.id + * WHERE ap.assignment_id IN (${…})` (`query-helpers.service.ts:83-86`, QH3). + * + * Deliberately **no** `COALESCE(display_name, username)` — unlike + * `AssignmentsService.getParticipants` (AS15), which does. The two + * participant projections genuinely disagree on the wire (plan3c inventory + * §1 QH3) and must stay that way; do not "fix" this one to match AS15. + * + * Empty `assignmentIds` short-circuits before any query, matching the + * legacy service method. + */ + async listForAssignments(assignmentIds: number[]): Promise { + if (assignmentIds.length === 0) return []; + // `ap.assignment` selects the plain FK scalar as `assignment_id` (never + // separately joined here). `ap.user` IS separately joined (to `u`), so + // its id is pulled explicitly off the join instead (`u.id as user_id`) + // — see `PlaceRatingsRepository.listForPlaces`'s docstring for why + // selecting a relation property that is ALSO an active join's target + // renames the column onto the joined entity's own primary key instead. + return this.qb('ap') + .join('ap.user', 'u') + .select(['ap.assignment', 'u.id as user_id', 'u.username', 'u.avatar']) + .where({ 'ap.assignment': { $in: assignmentIds } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended after Task 1's own + // `listForAssignments` above, per this task's coordination note (this file + // may already carry Task 1's uncommitted method; both are kept, and the + // whole file is committed under Task 3's message, noted in its report). + // --------------------------------------------------------------------------- + + /** + * AS2/AS15/AS31 (`AssignmentsService.getAssignmentWithPlace`/ + * `getParticipants`/`setParticipants`'s post-write re-select) — `SELECT + * ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar + * FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE + * ap.assignment_id = ?`. A SEPARATE method from `listForAssignments` + * (QH3) above — the inventory's §18.10 finding: the two participant + * projections genuinely disagree on the wire (this one COALESCEs + * `display_name` over `username`, QH3 does not) and must stay that way, + * never unified into one "shared row shaper". Also a NARROWER column set + * than QH3's own row: the legacy AS2/AS15/AS31 statement never selects + * `assignment_id` at all (unlike QH3, which does) — `ParticipantRow` below + * is deliberately its own type, not `ParticipantForAssignmentRow`, so a + * caller can never accidentally see a key this statement doesn't produce. + */ + async listWithDisplayName(assignment_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('ap') + .join('ap.user', 'u') + .select(['u.id as user_id', coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar']) + .where({ 'ap.assignment': assignment_id }) + .execute('all', false); + } + + /** AS29 — `DELETE FROM assignment_participants WHERE assignment_id = ?`. */ + async deleteForAssignment(assignment_id: number): Promise { + await this.nativeDelete({ assignment: assignment_id }); + } + + /** + * AS30 — `INSERT OR IGNORE INTO assignment_participants (assignment_id, + * user_id) VALUES (?, ?)`, run once per id. `upsertMany` with + * `onConflictAction: 'ignore'` on the table's real unique key + * (`UNIQUE(assignment_id, user_id)`, + * `Migration20200101002400_create_assignment_participants` — verified + * against the migration, not assumed), named by entity property + * (`assignment`/`user`, `OauthConsentsRepository.upsertGrant`'s precedent + * for a relation-backed `onConflictFields` list). The caller only ever + * passes a non-empty, already-roster-scoped array; the empty-array guard + * here is defensive, matching the service's own guard. + * + * **Two differences from the legacy per-row loop (Task 3 review M1, + * absorbed in Task 4), both benign today, documented so they stay that + * way on purpose:** + * 1. **Narrower failure mode.** `OR IGNORE` swallowed *any* constraint + * violation on a row; `ON CONFLICT (assignment_id, user_id) DO + * NOTHING` swallows only THAT unique index, and one row that violates + * a DIFFERENT constraint aborts the whole multi-row statement rather + * than being skipped. Unreachable today — AS28 roster-scopes the ids + * before this call, and the caller has already gated the assignment + * itself — but a concurrent delete of the assignment mid-request would + * now 500 where the legacy loop silently no-opped that one row. + * 2. **One extra statement.** `upsertMany` issues a reload `SELECT` after + * the insert (`generated SQL, captured: insert … on conflict (…) do + * nothing returning id` then `select … from assignment_participants + * as a0 where (…)`) that the legacy loop never ran. Harmless — the + * reloaded rows are fully hydrated and unchanged, so the enclosing + * `em.transactional`'s closing flush emits nothing for them — but it + * is a genuine extra round-trip and briefly populates the identity map + * inside the write transaction, worth knowing if a future change here + * ever needs to reason about statement counts. + */ + async insertIgnore(assignment_id: number, user_ids: number[]): Promise { + if (user_ids.length === 0) return; + await this.upsertMany( + user_ids.map((user_id) => ({ assignment: assignment_id, user: user_id })), + { onConflictFields: ['assignment', 'user'], onConflictAction: 'ignore' }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP53 (`trips.service.ts::copy`'s participants read) — `SELECT ap.* FROM + * assignment_participants ap JOIN day_assignments da ON da.id = + * ap.assignment_id JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?`, + * the two-hop join scoping participant rows to a trip. Only `assignment_id`/ + * `user_id` are returned — the legacy `ap.*`'s own `id` column is never + * used by the copy loop, only the two FK values it re-inserts (TP54, via + * {@link insertIgnore} above, once per row). + * + * `ap.assignment` is ALSO the target of this query's own `.join('ap.assignment', + * 'da')` — selecting it bare here would resolve through the join and emit + * `da__id`, not the FK scalar (`DayAssignmentsRepository + * .assignmentWithPlaceSelect`'s `category_id` docstring documents the same + * trap) — `columnRef` selects the literal `assignment_id` column instead. + * `ap.user` is NOT separately joined here, so it selects fine as `user_id` + * (`TripsRepository.getOwnerId`'s `'t.user'` precedent). + */ + async listForTrip(trip_id: number | string): Promise<{ assignment_id: number; user_id: number }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('ap') + .join('ap.assignment', 'da') + .join('da.day', 'd') + .select([columnRef(platform, 'ap.assignment_id').as('assignment_id'), 'ap.user']) + .where('d.trip_id = ?', [trip_id]) + .execute<{ assignment_id: number; user_id: number }[]>('all', false); + } +} diff --git a/server/src/db/repositories/AuditLog.repository.ts b/server/src/db/repositories/AuditLog.repository.ts new file mode 100644 index 000000000..2ed85642c --- /dev/null +++ b/server/src/db/repositories/AuditLog.repository.ts @@ -0,0 +1,128 @@ +import { AuditLog } from '../entities/AuditLog.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts` + * projection reads it via a `LEFT JOIN users`, not this shape — this is the + * bare table row). + */ +export interface AuditLogRow { + id: number; + created_at: string | null; + user_id: number | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; +} + +const _auditLogRowKeys: AssertRowKeys = true; + +/** The column set `insertEntry` writes; `id`/`created_at` are generated. */ +export type NewAuditLogRow = Omit; + +/** + * AD22's joined projection (`admin.service.ts#getAuditLog`): `a.id, + * a.created_at, a.user_id, u.username, u.email as user_email, a.action, + * a.resource, a.details, a.ip` from `audit_log a LEFT JOIN users u ON + * u.id = a.user_id`. Distinct from `AuditLogRow` (the bare-table shape + * `insertEntry` writes) — a deleted/never-existing user leaves `username`/ + * `user_email` both null (the `LEFT JOIN`, not an `INNER JOIN`), the row + * itself is still returned. The `created_at` ISO-suffix normalization and + * the `details` JSON-parse-with-fallback stay in `AdminService` — this + * repository returns the raw joined row untouched, same boundary the + * class-level docs elsewhere in this program draw between repository and + * service. + */ +export interface AuditLogPageRow { + id: number; + created_at: string | null; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; +} + +/** Kysely shape for the `listPage` join — only the columns it actually selects. */ +interface AuditLogKyselyDB { + audit_log: AuditLogRow; + users: { id: number; username: string; email: string }; +} + +export class AuditLogRepository extends TrekRepository { + /** + * The column set of the legacy INSERT: + * `INSERT INTO audit_log (user_id, action, resource, details, ip) + * VALUES (?, ?, ?, ?, ?)`. + * + * Best-effort, fire-and-forget, exactly like the caller + * (`AuditService.writeAudit`, wrapped in an outer try/catch that never + * throws): no `refresh`, nothing the caller reads — MikroORM appends + * `RETURNING id, created_at` for the defaults, but `insertEntry` resolves + * `void`. + * + * A native `em.insert()`, not `create()` + `flush()`: `flush()` commits + * the *whole* unit of work of the request's `EntityManager`, not just this + * row — with ~98 call sites for `writeAudit`, one of them firing before + * every MCP tool handler runs, an unrelated pending change elsewhere in + * the same request would be committed early by an audit write, and the + * outer try/catch that makes this best-effort would swallow whatever that + * flush raised. `em.insert()` fires a single native INSERT with no side + * effects on the context/identity map — the same fire-and-forget + * semantics as the legacy `dbs.run(INSERT …)` autocommit statement. + * + * Named `insertEntry`, not `insert`: `EntityRepository` (`@mikro-orm/core`) + * already declares a native `insert(data, options?): Promise>` + * (bypassing `create`/`persist`/`flush`), which is a `tsc`-verified + * incompatible override for a `Promise` method of the same name — the + * same reason `Days.repository.ts`/`DayNotes.repository.ts` name their + * inserts `createDay`/`createNote` rather than `create`. + */ + async insertEntry(entry: NewAuditLogRow): Promise { + await this.insert({ + user: entry.user_id, + action: entry.action, + resource: entry.resource, + details: entry.details, + ip: entry.ip, + }); + } + + /** + * AD22 (`admin.service.ts#getAuditLog`) — `SELECT a.id, a.created_at, + * a.user_id, u.username, u.email as user_email, a.action, a.resource, + * a.details, a.ip FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + * ORDER BY a.id DESC LIMIT ? OFFSET ?`. Kysely, not the ORM's own + * QueryBuilder — the same `CollabMessagesRepository#joinedQuery` shape + * every other flat-projection join in this program uses: `LEFT JOIN` + * keeps a deleted-user's row (both `username`/`user_email` come back + * null rather than dropping the row), and the projection is nine flat + * scalars, not a hydrated `Users` entity. + */ + async listPage(limit: number, offset: number): Promise { + return await this.kysely() + .selectFrom('audit_log as a') + .leftJoin('users as u', 'u.id', 'a.user_id') + .select(['a.id', 'a.created_at', 'a.user_id', 'u.username', 'u.email as user_email', 'a.action', 'a.resource', 'a.details', 'a.ip']) + .orderBy('a.id', 'desc') + .limit(limit) + .offset(offset) + .execute(); + } + + /** + * AD23 (`admin.service.ts#getAuditLog`) — `SELECT COUNT(*) as c FROM + * audit_log`. `TrekRepository#count` (no filter) already renders the + * equivalent `COUNT(*)` over the whole table; named explicitly here (a + * thin `super.count()` pass-through) so the reserved AD23 call site has + * its own documented anchor rather than admin's service code calling the + * inherited method by its generic name. + */ + override async count(): Promise { + return await super.count(); + } +} diff --git a/server/src/db/repositories/BucketList.repository.ts b/server/src/db/repositories/BucketList.repository.ts new file mode 100644 index 000000000..d560ce5e0 --- /dev/null +++ b/server/src/db/repositories/BucketList.repository.ts @@ -0,0 +1,333 @@ +import { lowerTrim, lowerTrimParam } from '../dialect/sql-functions'; +import type { BucketList } from '../entities/BucketList.entity'; +import { presenceSet } from './_shared/presence-set'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `bucket_list` — every scalar column, `AT30`/`AT33`/`AT34`'s `SELECT *` shape. */ +export interface BucketListRow { + id: number; + user_id: number; + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + created_at: string | null; + target_date: string | null; + visited_at: string | null; + visited_source: string | null; + region_code: string | null; +} + +/** + * Three separate table shapes for the SAME `bucket_list` table (the + * `VisitedCountriesRepository`'s own docstring explains why one shared + * shape doesn't work for both reads and writes): the full row for + * {@link listForUser}/{@link findById}/{@link findForUser}'s `SELECT *`; + * the exact 8-column legacy `INSERT` list for {@link insertItem} (`id`, + * `created_at`, `visited_at`, `visited_source` are never bound — + * `created_at` via the schema's `DEFAULT CURRENT_TIMESTAMP`, the rest via + * SQLite's ordinary "omitted nullable column ⇒ NULL"); `id`/`user_id` plus + * the six settable columns for {@link update}/{@link deleteForUser}, which + * only ever `.set()`/`.where()` — never `.values()` — so no insert-only + * column needs excluding there. + */ +interface BucketListReadKyselyDB { + bucket_list: BucketListRow; +} +interface BucketListInsertKyselyDB { + bucket_list: { user_id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null; region_code: string | null }; +} +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 { + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + target_date: string | null; +} + +/** + * `bucket_list` — a user's travel wishlist (Plan 3f Task 1, atlas). See + * {@link findDuplicate}'s own docstring for the #1898 dedup identity; + * {@link update}'s for the `?? null` vs `|| null` lat/lng-of-zero fix this + * repository preserves rather than regresses. + */ +export class BucketListRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private insertDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + private visitDb() { + return this.kysely(); + } + + /** AT30 (`bucketList`) — `SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('user_id', '=', userId).orderBy('created_at', 'desc').execute(); + } + + /** + * AT31 (`findDuplicateBucketItem`, #1898) — `SELECT id FROM bucket_list + * WHERE user_id = ? AND lower(trim(name)) = lower(trim(?)) AND + * country_code IS ? AND target_date IS ? AND lat IS ? AND lng IS ? AND id + * IS NOT ? LIMIT 1`. Every nullable comparison (`country_code`/ + * `target_date`/`lat`/`lng`/`id`) is a bound-parameter `IS`/`IS NOT` in + * the legacy statement — NULL-safe on BOTH sides, so "no coordinates" + * matches "no coordinates" instead of SQL's usual `NULL = NULL` ⇒ false. + * MikroORM's own filter-object compiler reproduces this exactly with NO + * raw SQL needed: a bare `{ col: value }` condition renders `col = ?` + * when `value` is non-null and the LITERAL `col is null` when `value` is + * null (`QueryBuilderHelper.js`'s `processObjectSubCondition`, `const op = + * cond[key] === null ? 'is' : '='`), re-evaluated FRESH on every call + * (never a cached prepared statement, unlike the legacy `?`-bound `IS`) — + * so passing `key.country_code`/`key.target_date`/`key.lat`/`key.lng` + * straight through, whatever their value, reproduces the legacy + * NULL-safe match on every one of the four columns. `id: { $ne: excludeId + * ?? null }` gets the identical treatment for `$ne` specifically + * (`getOperatorReplacement`'s `value[op] === null` branch): `excludeId ?? + * null` renders `id is not null` (always true — `id` is never null) when + * `create` calls with no id to exclude, and `id != ?` (bound) when + * `update` passes its own row's id — the same two branches the legacy + * `IS NOT ?` bound with a possibly-null parameter produced. + * `lowerTrim`/`lowerTrimParam` (Plan 3f Task 0, R9) reproduce + * `lower(trim(name)) = lower(trim(?))` as a filter KEY, the + * `SchoolHolidayRegionsRepository.findIdByNameCI`/`collateNoCase` + * precedent for a dialect helper spread into a `findOne` filter object. + * Proven against all-NULL AND all-set fixture rows by a dedicated parity + * test (`bucket-list-duplicate.parity.test.ts` — see this task's report). + */ + async findDuplicate(userId: number, key: BucketListIdentity, excludeId: number | null): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { + user: userId, + [lowerTrim(platform, 'name')]: lowerTrimParam(platform, key.name), + country_code: key.country_code, + target_date: key.target_date, + lat: key.lat, + lng: key.lng, + id: { $ne: excludeId ?? null }, + }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** AT32 (`createBucketItem`) — `INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, region_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`. Returns the new row's id. */ + async insertItem(row: { + user_id: number; + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + target_date: string | null; + region_code: string | null; + }): Promise { + const result = await this.insertDb().insertInto('bucket_list').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** AT33 (`createBucketItem`'s post-insert re-select, NOT user-scoped — the legacy statement's own shape) — `SELECT * FROM bucket_list WHERE id = ?`. */ + async findById(id: number | string): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).executeTakeFirst(); + } + + /** AT34/AT36/AT37 (`updateBucketItem`'s guard, its post-update re-select, `deleteBucketItem`'s guard — identical text, three call sites) — `SELECT * FROM bucket_list WHERE id = ? AND user_id = ?`. */ + async findForUser(id: number | string, userId: number): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).where('user_id', '=', userId).executeTakeFirst(); + } + + /** + * AT35 (`updateBucketItem`) — the 6-column `UPDATE bucket_list SET name = + * COALESCE(?, name), notes = CASE WHEN ? THEN ? ELSE notes END, lat = + * CASE WHEN ? THEN ? ELSE lat END, lng = CASE WHEN ? THEN ? ELSE lng END, + * country_code = CASE WHEN ? THEN ? ELSE country_code END, target_date = + * CASE WHEN ? THEN ? ELSE target_date END WHERE id = ? AND user_id = ?`. + * `name` keeps the legacy `COALESCE(?, name)` TRUTHY-wins semantics (the + * caller resolves `present = !!value`, same as + * `PackingBagsRepository.update`'s `name`/`color`); the other five are + * true presence sentinels (`present = data.field !== undefined`), already + * resolved by the SERVICE per `_shared/presence-set.ts`'s own contract — + * this repository writes exactly the value it is handed, `?? null` never + * `|| null` (the class docstring's lat/lng-of-zero fix: the SERVICE binds + * `data.lat ?? null`, not `data.lat || null`, so an explicit `0` survives). + * An empty write short-circuits before the `UPDATE`, the + * `PackingBagsRepository`/`BudgetItems`/`TodoItems` precedent for + * `presenceSet` returning `{}`. + */ + async update( + id: number | string, + userId: number, + write: { + name?: readonly [present: boolean, value: string]; + notes?: readonly [present: boolean, value: string | null]; + lat?: readonly [present: boolean, value: number | null]; + lng?: readonly [present: boolean, value: number | null]; + country_code?: readonly [present: boolean, value: string | null]; + target_date?: readonly [present: boolean, value: string | null]; + }, + ): Promise { + const data = presenceSet<{ + name: string; + notes: string | null; + lat: number | null; + lng: number | null; + country_code: string | null; + target_date: string | null; + }>(write); + if (Object.keys(data).length === 0) return; + await this.writeDb().updateTable('bucket_list').set(data).where('id', '=', id as number).where('user_id', '=', userId).execute(); + } + + /** AT38 (`deleteBucketItem`) — `DELETE FROM bucket_list WHERE id = ? AND user_id = ?`. */ + async deleteForUser(id: number | string, userId: number): Promise { + await this.writeDb().deleteFrom('bucket_list').where('id', '=', id as number).where('user_id', '=', userId).execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 3 (`DawarichSuggestionsService`/`DawarichSyncService`) — + // additive. Cross-domain reads/writes from the Dawarich integration (3h) + // into this 3f-owned table. + // --------------------------------------------------------------------------- + + /** + * DWS9 — `acceptAsBucketTick`: `UPDATE bucket_list SET visited_at = ?, + * visited_source = 'dawarich' WHERE id = ? AND user_id = ?`, + * unconditional (DWS8's own `findForUser`-shaped guard read already + * confirmed the row exists and belongs to the caller before this write). + */ + async markVisited(id: number, userId: number, visitedAt: string): Promise { + await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: visitedAt, visited_source: 'dawarich' }) + .where('id', '=', id) + .where('user_id', '=', userId) + .execute(); + } + + /** + * DWS12 — `scanBucketList`: `SELECT id, name, lat, lng, visited_at FROM + * bucket_list WHERE user_id = ? ORDER BY (visited_at IS NOT NULL), + * created_at DESC, id DESC`. The boolean-expression `ORDER BY` key is + * portable SQL — no dialect helper needed. + */ + async listForScan(userId: number): Promise<{ id: number; name: string; lat: number | null; lng: number | null; visited_at: string | null }[]> { + return await this.readDb() + .selectFrom('bucket_list') + .select(['id', 'name', 'lat', 'lng', 'visited_at']) + .where('user_id', '=', userId) + .orderBy((eb) => eb('visited_at', 'is not', null)) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .execute(); + } + + /** + * DWS13 — `confirmBucketVisits` (looped inside the SERVICE's own + * `uow.transactional` block): `UPDATE bucket_list SET visited_at = ?, + * visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at + * IS NULL` — conditional, won't clobber an existing tick. Returns the + * affected-row count so the caller can total how many were actually + * updated. + */ + async markVisitedIfUnset(id: number, userId: number, visitedAt: string): Promise { + const result = await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: visitedAt, visited_source: 'dawarich' }) + .where('id', '=', id) + .where('user_id', '=', userId) + .where('visited_at', 'is', null) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0); + } + + /** DWS15 — `clearBucketVisit`: `UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?`. Returns whether a row was actually touched. */ + async clearVisited(id: number, userId: number): Promise { + const result = await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: null, visited_source: null }) + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + /** + * DSY10 — `dawarich-sync.service.ts::matchBucketList`'s bounding-box + * pre-filter: `SELECT id, lat, lng FROM bucket_list WHERE user_id = ? AND + * lat IS NOT NULL AND lng IS NOT NULL AND lat BETWEEN ? AND ? AND lng + * BETWEEN ? AND ?`. A rough box only — the real distance test (haversine) + * stays in the SERVICE, in JS. Typed `lat`/`lng` nullable to match the + * column's own schema type; the `IS NOT NULL` conditions guarantee they + * are never actually null in a returned row. + */ + async listInBoundingBox( + userId: number, + latMin: number, + latMax: number, + lngMin: number, + lngMax: number, + ): Promise<{ id: number; lat: number | null; lng: number | null }[]> { + return await this.readDb() + .selectFrom('bucket_list') + .select(['id', 'lat', 'lng']) + .where('user_id', '=', userId) + .where('lat', 'is not', null) + .where('lng', 'is not', null) + .where('lat', '>=', latMin) + .where('lat', '<=', latMax) + .where('lng', '>=', lngMin) + .where('lng', '<=', lngMax) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::listBucketList`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT name, lat, lng, country_code, notes, target_date FROM bucket_list + * WHERE user_id = ? ORDER BY created_at DESC, id DESC`. Deliberately NOT + * {@link listForUser} (which `SELECT *`s and orders by `created_at DESC` + * only, no `id` tiebreak, for the Atlas UI's own list) — a genuinely + * different legacy statement on the same table (rule 2), not a variant + * worth merging. + */ + async listForPublicApi(userId: number): Promise { + return await this.readDb() + .selectFrom('bucket_list') + .select(['name', 'lat', 'lng', 'country_code', 'notes', 'target_date']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .execute(); + } +} + +/** {@link BucketListRepository.listForPublicApi}'s projection. */ +export interface PublicApiBucketListItemRow { + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + target_date: string | null; +} diff --git a/server/src/db/repositories/BudgetCategoryOrder.repository.ts b/server/src/db/repositories/BudgetCategoryOrder.repository.ts new file mode 100644 index 000000000..da79c722a --- /dev/null +++ b/server/src/db/repositories/BudgetCategoryOrder.repository.ts @@ -0,0 +1,74 @@ +import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +interface BudgetCategoryOrderKyselyDB { + budget_category_order: { trip_id: number; category: string; sort_order: number }; +} + +/** + * `budget_category_order` — a composite-PK `(trip_id, category)` table (no + * surrogate `id`, per the entity's `[PrimaryKeyProp]?: ['trip', 'category']` + * — `BudgetItemContributors`-class shape, R5). Kysely throughout: the + * `ON CONFLICT(trip_id, category) DO UPDATE` upsert (BG84) has a real + * two-column conflict target Kysely expresses directly, and every other + * write here is a plain `INSERT OR IGNORE`/`INSERT` the entity's composite + * key would otherwise force through `em.upsert`'s narrower single-target + * form. + */ +export class BudgetCategoryOrderRepository extends TrekRepository { + /** BG22/BG41 — `SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?`. */ + async exists(trip_id: number | string, category: string): Promise { + const row = await this.kysely() + .selectFrom('budget_category_order') + .select((eb) => eb.val(1).as('one')) + .where('trip_id', '=', trip_id as number) + .where('category', '=', category) + .executeTakeFirst(); + return row !== undefined; + } + + /** BG23/BG42 — `SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely() + .selectFrom('budget_category_order') + .select((eb) => eb.fn.max('sort_order').as('max')) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + return row?.max ?? null; + } + + /** BG24/BG43 — `INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?)`. */ + async insertIgnore(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** BG84 (`reorderCategories`) — `INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?) ON CONFLICT(trip_id,category) DO UPDATE SET sort_order = excluded.sort_order`, one row per call (the legacy `prepare(...).run(...)` loop). */ + async upsertSortOrder(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .onConflict((oc) => oc.columns(['trip_id', 'category']).doUpdateSet({ sort_order })) + .execute(); + } + + /** TP74 (`TripsService.copy`) — `SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?`. */ + async listForTrip(trip_id: number | string): Promise<{ category: string; sort_order: number }[]> { + return await this.kysely() + .selectFrom('budget_category_order') + .select(['category', 'sort_order']) + .where('trip_id', '=', trip_id as number) + .execute(); + } + + /** TP75 (`TripsService.copy`) — `INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?)`, plain (not `OR IGNORE` — the copy's own trip_id has no existing rows yet). */ + async insertCopy(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItemMembers.repository.ts b/server/src/db/repositories/BudgetItemMembers.repository.ts new file mode 100644 index 000000000..aaef58bd5 --- /dev/null +++ b/server/src/db/repositories/BudgetItemMembers.repository.ts @@ -0,0 +1,182 @@ +import type { BudgetItemMembers } from '../entities/BudgetItemMembers.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `budget_item_members` row — every scalar column, incl. the two `persist(false)` relation mirrors. */ +export interface BudgetItemMemberRow { + id: number; + budget_item_id: number; + user_id: number; + paid: number; + amount: number | null; +} + +const _budgetItemMemberRowKeys: AssertRowKeys = true; + +/** BG1/BG11's joined projection — the member row plus the display fields `avatarUrl()` needs. */ +export interface BudgetItemMemberWithUserRow { + user_id: number; + paid: number; + amount: number | null; + username: string; + avatar: string | null; +} + +interface BudgetItemMembersKyselyDB { + budget_item_members: BudgetItemMemberRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** The insert-only shape — `id` is autoincrement and omitted from `.values()` below, matching `FileLinksWriteKyselyDB`'s precedent. */ +interface BudgetItemMembersWriteKyselyDB { + budget_item_members: { budget_item_id: number; user_id: number; paid: number; amount: number | null }; +} + +/** + * `budget_item_members` — who splits a budget item, and whether they've + * paid their share. Kysely throughout: `budget_item_id`/`user_id` are + * `persist(false)` relation mirrors (the program-wide trap), and the + * `INSERT OR IGNORE` write has no `em.upsert`-expressible single conflict + * target that also matches every legacy column combination (some calls omit + * `amount`, some pin it `NULL`) — Kysely's `ON CONFLICT DO NOTHING` is the + * exact SQLite equivalent, matching the legacy statement. + */ +export class BudgetItemMembersRepository extends TrekRepository { + /** BG1 — `SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN users u WHERE bm.budget_item_id = ?`. */ + async listForItem(budget_item_id: number): Promise { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', '=', budget_item_id) + .execute(); + } + + /** BG11 (`listBudgetItems`'s member batch) — same projection as {@link listForItem}, `budget_item_id IN (dynamic)`, plus the item id for the caller to bucket rows by. */ + async listForItems(budget_item_ids: number[]): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', 'in', budget_item_ids) + .execute(); + } + + /** BG58 (`updateMembers`'s pre-image read) — `SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?`. */ + async listUserPaid(budget_item_id: number): Promise<{ user_id: number; paid: number }[]> { + return await this.kysely() + .selectFrom('budget_item_members') + .select(['user_id', 'paid']) + .where('budget_item_id', '=', budget_item_id) + .execute(); + } + + /** TP62 (`TripsService.copy`'s bare member read) — `SELECT bm.* FROM budget_item_members bm JOIN budget_items b ON b.id = bm.budget_item_id WHERE b.trip_id = ?`. */ + async listRawForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as b', 'b.id', 'bm.budget_item_id') + .select(['bm.id', 'bm.budget_item_id', 'bm.user_id', 'bm.paid', 'bm.amount']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG73 (`calculateSettlement`'s member read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. No `paid` column — unlike {@link listForItem}, this legacy statement never selected it. */ + async listForTripWithUsers(trip_id: number | string): Promise<{ budget_item_id: number; user_id: number; amount: number | null; username: string; avatar: string | null }[]> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as b', 'b.id', 'bm.budget_item_id') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** + * BG94 (`getPerPersonSummary`, #2525) — `SELECT bm.budget_item_id, + * bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS + * username, u.avatar FROM budget_item_members bm JOIN budget_items bi ON + * bm.budget_item_id = bi.id JOIN users u ON bm.user_id = u.id WHERE + * bi.trip_id = ?`. Like {@link listForTripWithUsers}, plus `paid`. + */ + async listForTripWithUsersAndPaid(trip_id: number | string): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as bi', 'bi.id', 'bm.budget_item_id') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bi.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG64 (`removeUserFromBudgetItems`) — `SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?`. */ + async listItemIdsForUser(user_id: number): Promise { + const rows = await this.kysely() + .selectFrom('budget_item_members') + .select('budget_item_id') + .distinct() + .where('user_id', '=', user_id) + .execute(); + return rows.map(r => r.budget_item_id); + } + + /** BG65 — `DELETE FROM budget_item_members WHERE user_id = ?`. */ + async deleteForUser(user_id: number): Promise { + await this.kysely().deleteFrom('budget_item_members').where('user_id', '=', user_id).execute(); + } + + /** BG66 (`removeUserFromBudgetItems`'s per-item recount, looped) — `SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?`. */ + async countForItem(budget_item_id: number): Promise { + const row = await this.kysely() + .selectFrom('budget_item_members') + .select((eb) => eb.fn.countAll().as('count')) + .where('budget_item_id', '=', budget_item_id) + .executeTakeFirst(); + return row?.count ?? 0; + } + + /** BG70 (`toggleMemberPaid`'s re-select) — `SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN users u WHERE bm.budget_item_id = ? AND bm.user_id = ?`. No `amount` column — unlike {@link listForItem}, this legacy statement never selected it. */ + async findMemberWithUser(budget_item_id: number, user_id: number): Promise<{ user_id: number; paid: number; username: string; avatar: string | null } | undefined> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.user_id', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', '=', budget_item_id) + .where('bm.user_id', '=', user_id) + .executeTakeFirst(); + } + + /** BG69 — `UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?`. */ + async setPaid(budget_item_id: number, user_id: number, paid: number): Promise { + await this.kysely() + .updateTable('budget_item_members') + .set({ paid }) + .where('budget_item_id', '=', budget_item_id) + .where('user_id', '=', user_id) + .execute(); + } + + /** BG35/BG38/BG59 — `DELETE FROM budget_item_members WHERE budget_item_id = ?` (replace-all before a re-insert). */ + async deleteForItem(budget_item_id: number): Promise { + await this.kysely().deleteFrom('budget_item_members').where('budget_item_id', '=', budget_item_id).execute(); + } + + /** + * BG26/BG27/BG36/BG39/BG60/TP63 — `INSERT OR IGNORE INTO + * budget_item_members (budget_item_id, user_id, paid, amount) VALUES + * (?,?,?,?)`, one row per call (the legacy `prepare(...).run(...)` loop — + * the service calls this once per member, matching the legacy statement + * text exactly for every call site: some pin `paid` to 0 and pass a real + * or `NULL` amount, one (`updateMembers`) passes an existing `paid` flag + * with no `amount` column at all — `paid`/`amount` default here to the + * 0/`NULL` shape the omitting call sites bound literally). + */ + async insertIgnore(row: { budget_item_id: number; user_id: number; paid?: number; amount?: number | null }): Promise { + await this.kysely() + .insertInto('budget_item_members') + .values({ budget_item_id: row.budget_item_id, user_id: row.user_id, paid: row.paid ?? 0, amount: row.amount ?? null }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItemPayers.repository.ts b/server/src/db/repositories/BudgetItemPayers.repository.ts new file mode 100644 index 000000000..19d3ad0d9 --- /dev/null +++ b/server/src/db/repositories/BudgetItemPayers.repository.ts @@ -0,0 +1,95 @@ +import type { BudgetItemPayers } from '../entities/BudgetItemPayers.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `budget_item_payers` row — every scalar column, incl. the two `persist(false)` relation mirrors. */ +export interface BudgetItemPayerRow { + id: number; + budget_item_id: number; + user_id: number; + amount: number; +} + +const _budgetItemPayerRowKeys: AssertRowKeys = true; + +/** BG2/BG12's joined projection. */ +export interface BudgetItemPayerWithUserRow { + user_id: number; + amount: number; + username: string; + avatar: string | null; +} + +interface BudgetItemPayersKyselyDB { + budget_item_payers: BudgetItemPayerRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** The insert-only shape — `id` is autoincrement and omitted from `.values()` below, matching `FileLinksWriteKyselyDB`'s precedent. */ +interface BudgetItemPayersWriteKyselyDB { + budget_item_payers: { budget_item_id: number; user_id: number; amount: number }; +} + +/** + * `budget_item_payers` — who actually paid a budget item, and how much. + * Kysely throughout, same reasoning as `BudgetItemMembersRepository`'s class + * docstring (`persist(false)` relation mirrors, an `INSERT OR IGNORE` with + * no single `em.upsert` conflict target). + */ +export class BudgetItemPayersRepository extends TrekRepository { + /** BG2 — `SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_payers bp JOIN users u WHERE bp.budget_item_id = ?`. */ + async listForItem(budget_item_id: number): Promise { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bp.budget_item_id', '=', budget_item_id) + .execute(); + } + + /** BG12 (`listBudgetItems`'s payer batch) — same projection, `budget_item_id IN (dynamic)`, plus the item id for the caller to bucket rows by. */ + async listForItems(budget_item_ids: number[]): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bp.budget_item_id', 'in', budget_item_ids) + .execute(); + } + + /** TP64 (`TripsService.copy`'s bare payer read) — `SELECT bp.* FROM budget_item_payers bp JOIN budget_items b ON b.id = bp.budget_item_id WHERE b.trip_id = ?`. */ + async listRawForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('budget_items as b', 'b.id', 'bp.budget_item_id') + .select(['bp.id', 'bp.budget_item_id', 'bp.user_id', 'bp.amount']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG74 (`calculateSettlement`'s payer read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. */ + async listForTripWithUsers(trip_id: number | string): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('budget_items as b', 'b.id', 'bp.budget_item_id') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG4 — `DELETE FROM budget_item_payers WHERE budget_item_id = ?` (replace-all before a re-insert). */ + async deleteForItem(budget_item_id: number): Promise { + await this.kysely().deleteFrom('budget_item_payers').where('budget_item_id', '=', budget_item_id).execute(); + } + + /** BG5/TP65 — `INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?,?,?)`, one row per call (the legacy `prepare(...).run(...)` loop). */ + async insertIgnore(row: { budget_item_id: number; user_id: number; amount: number }): Promise { + await this.kysely() + .insertInto('budget_item_payers') + .values({ budget_item_id: row.budget_item_id, user_id: row.user_id, amount: row.amount }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItems.repository.ts b/server/src/db/repositories/BudgetItems.repository.ts new file mode 100644 index 000000000..0d7f1decf --- /dev/null +++ b/server/src/db/repositories/BudgetItems.repository.ts @@ -0,0 +1,542 @@ +import type { BudgetItems } from '../entities/BudgetItems.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; + +/** A bare `budget_items` row — every scalar column, incl. the four `persist(false)` relation mirrors (the program-wide trap this class goes through Kysely to avoid). */ +export interface BudgetItemRow { + id: number; + trip_id: number; + category: string; + name: string; + total_price: number; + persons: number | null; + days: number | null; + note: string | null; + sort_order: number | null; + created_at: string | null; + paid_by_user_id: number | null; + expense_date: string | null; + reservation_id: number | null; + currency: string | null; + exchange_rate: number; + ticket_json: string | null; + place_id: number | null; +} + +const _budgetItemRowKeys: AssertRowKeys = true; + +/** BG3/BG13's receipt projection (`loadItemReceipts`). */ +export interface BudgetReceiptRow { + id: number; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; + trip_id: number; +} + +/** BG7's receipt-link projection (`unlinkReceipts`'s pre-read). */ +export interface BudgetReceiptLinkRow { + id: number; + file_id: number; + reservation_id: number | null; + assignment_id: number | null; + place_id: number | null; +} + +/** BG88's projection: what a row needs to be converted into the trip currency. */ +export interface BudgetItemMoneyRow { + id: number; + category: string; + total_price: number; + currency: string | null; + exchange_rate: number; +} + +interface BudgetKyselyDB { + budget_items: BudgetItemRow; + budget_item_members: { id: number; budget_item_id: number; user_id: number; paid: number; amount: number | null }; + budget_category_order: { trip_id: number; category: string; sort_order: number }; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; + trip_files: { id: number; filename: string; original_name: string; file_size: number | null; mime_type: string | null; trip_id: number; deleted_at: string | null; created_at: string | null }; + file_links: { id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null; budget_item_id: number | null; created_at: string | null }; +} + +/** + * The insert-only shape for `create` (BG25) — omits `id`/`created_at` + * (autoincrement/`DEFAULT CURRENT_TIMESTAMP`, matching the legacy column + * list) and `paid_by_user_id` (never written by `BudgetService` — R11/§17 + * surprise 7, only `UserCleanupService`'s UC5 touches it). + */ +interface BudgetItemsInsertKyselyDB { + budget_items: { + trip_id: number | string; category: string; name: string; total_price: number; currency: string | null; + exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }; +} + +/** The insert-only shape for `insertCopy` (TP61) — a distinct column set/order from `create`'s, carrying `paid_by_user_id` verbatim (§17). */ +interface BudgetItemsCopyInsertKyselyDB { + budget_items: { + trip_id: number | string; category: string; name: string; total_price: number; persons: number | null; days: number | null; + note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null; + exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null; + }; +} + +/** + * `budget_items` — the expense rows themselves, plus this domain's own view + * of its receipts (a join across `trip_files`/`file_links`, the same way + * `BudgetSettlements.SETTLEMENT_SELECT` joins `users`). Kysely throughout: + * `trip_id`/`paid_by_user_id`/`reservation_id`/`place_id` are all + * `persist(false)` relation mirrors (§16) — the program-wide bare-`select` + * drop trap `TripFilesRepository`'s class docstring names, avoided the same + * way here. + */ +export class BudgetItemsRepository extends TrekRepository { + /** + * FL4 (`FilesService.findForeignLinkTarget`'s budget-expense branch, R12) + * — kept from Task 1, unchanged. + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('b') + .select([columnRef(platform, 'b.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * BG10 (`listBudgetItems`) — `SELECT bi.* FROM budget_items bi LEFT JOIN + * budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = + * bi.category WHERE bi.trip_id = ? ORDER BY COALESCE(bco.sort_order, + * 999999) ASC, bi.sort_order ASC`. + */ + async listWithCategoryOrder(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_items as bi') + .leftJoin('budget_category_order as bco', (join) => join.onRef('bco.trip_id', '=', 'bi.trip_id').onRef('bco.category', '=', 'bi.category')) + .selectAll('bi') + .where('bi.trip_id', '=', trip_id as number) + .orderBy((eb) => eb.fn.coalesce('bco.sort_order', eb.val(999999)), 'asc') + .orderBy('bi.sort_order', 'asc') + .execute(); + } + + /** BG14 — `SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the program's gate-level id parsing carry: its one caller, `freezeForeignRate`, is only reached with `BudgetController.update`'s `toRowId`-parsed id). */ + async getCurrency(id: number, trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('budget_items').select('currency').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.currency; + } + + /** BG31/BG32/BG57 — `SELECT * FROM budget_items WHERE id = ? AND trip_id = ?`, the full-row trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}; `trip_id` stays `number | string`, a separate, still-accepted carry. */ + async findInTrip(id: number, trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG50/BG68 — `SELECT id FROM budget_items WHERE id = ? AND trip_id = ?`, the id-only trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async existsInTrip(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('budget_items').select('id').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG52 (`deleteBudgetItem`'s pre-image) — `SELECT id, reservation_id FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async findForDelete(id: number, trip_id: number | string): Promise<{ id: number; reservation_id: number | null } | undefined> { + return await this.kysely().selectFrom('budget_items').select(['id', 'reservation_id']).where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG30/BG49/BG51/BG63 — `SELECT * FROM budget_items WHERE id = ?`, the post-write re-select (no trip filter — the caller already knows `id` is in-scope, having just written it). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async findById(id: number): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** BG21 — `SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('budget_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.max ?? null; + } + + /** + * BG25 (`createBudgetItem`) — `INSERT INTO budget_items (14 cols) VALUES + * (?×14)`. Returns the new row's id (BG30's re-select key). Named + * `insertItem`, not `create`/`insert` — both collide with + * `TrekRepository`/`EntityRepository`'s own same-named methods (a + * different signature: an unpersisted-entity builder, not an INSERT). + */ + async insertItem(row: { + trip_id: number | string; category: string; name: string; total_price: number; currency: string | null; + exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }): Promise { + const result = await this.kysely().insertInto('budget_items').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * TP61 (`TripsService.copy`) — `INSERT INTO budget_items (trip_id, + * category, name, total_price, persons, days, note, sort_order, + * reservation_id, currency, exchange_rate, expense_date, ticket_json, + * paid_by_user_id) VALUES (?×14)`, a distinct column set/order from + * {@link create}'s — carries `paid_by_user_id` verbatim (§17, the "dead" + * column survives a copy unexamined, matching legacy exactly). + */ + async insertCopy(row: { + trip_id: number | string; category: string; name: string; total_price: number; persons: number | null; days: number | null; + note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null; + exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null; + }): Promise { + const result = await this.kysely().insertInto('budget_items').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * BG33 (`updateBudgetItem`) — the 13-column presence-sentinel `UPDATE` + * (the last two, `reservation_id`/`place_id`, link an expense to a booking + * or a place, #2084) + * (R11's helper — {@link presenceSet}; land here first, Tasks 3/4/5 copy + * this shape). `category`/`name` keep the legacy `COALESCE(?, col)` + * truthy-wins semantics (the caller passes `present = !!value`, matching + * `data.category || null`'s fall-through-on-falsy exactly); every other + * column is a true presence sentinel (`present = data.field !== undefined`). + */ + /** `id: number` — same Plan 4 Task 8b (U6) narrowing as {@link getCurrency} (its one caller, `updateBudgetItem`, is only reached with a `toRowId`-parsed id). */ + async update(id: number, write: { + category?: readonly [present: boolean, value: string]; + name?: readonly [present: boolean, value: string]; + total_price?: readonly [present: boolean, value: number]; + currency?: readonly [present: boolean, value: string | null]; + exchange_rate?: readonly [present: boolean, value: number]; + persons?: readonly [present: boolean, value: number | null]; + days?: readonly [present: boolean, value: number | null]; + note?: readonly [present: boolean, value: string | null]; + ticket_json?: readonly [present: boolean, value: string | null]; + sort_order?: readonly [present: boolean, value: number]; + expense_date?: readonly [present: boolean, value: string | null]; + reservation_id?: readonly [present: boolean, value: number | null]; + place_id?: readonly [present: boolean, value: number | null]; + }): Promise { + const data = presenceSet<{ + category: string; name: string; total_price: number; currency: string | null; exchange_rate: number; + persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }>(write); + if (Object.keys(data).length === 0) return; + await this.kysely().updateTable('budget_items').set(data).where('id', '=', id).execute(); + } + + /** BG6/BG34 — `UPDATE budget_items SET total_price = ? WHERE id = ?`. */ + async setTotalPrice(id: number | string, total_price: number): Promise { + await this.kysely().updateTable('budget_items').set({ total_price }).where('id', '=', id as number).execute(); + } + + /** BG37/BG40/BG61/BG62 — `UPDATE budget_items SET persons = ? WHERE id = ?` (`persons: null` is BG62's clear branch). */ + async setPersons(id: number | string, persons: number | null): Promise { + await this.kysely().updateTable('budget_items').set({ persons }).where('id', '=', id as number).execute(); + } + + /** BG83 (`reorderItems`, looped) — `UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */ + async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise { + await this.kysely().updateTable('budget_items').set({ sort_order }).where('id', '=', id as number).where('trip_id', '=', trip_id as number).execute(); + } + + /** BG53 — `DELETE FROM budget_items WHERE id = ?` (no trip scoping in the statement itself — relies on the caller's prior {@link findForDelete} gate, matching legacy). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('budget_items').where('id', '=', id).execute(); + } + + /** RS49 — `DELETE FROM budget_items WHERE id IN (dynamic)`, every expense the booking carried (#2084); a bound list, no interpolated SQL. */ + async deleteByIds(ids: number[]): Promise { + if (ids.length === 0) return; + await this.kysely().deleteFrom('budget_items').where('id', 'in', ids).execute(); + } + + /** BG72 (`calculateSettlement`) — `SELECT * FROM budget_items WHERE trip_id = ?`. */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).execute(); + } + + /** + * `share.service.ts:391` SH14 (`getSharedTripData`'s share_budget read) — + * `SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC` + * (unlike BG72's `listAllForTrip` above, which has no `ORDER BY`). + */ + async listPublicForShare(trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('category', 'asc').execute(); + } + + /** + * BG88 (`tripTotals`, `getPerPersonSummary`) — `SELECT id, category, + * total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? + * ORDER BY id`. Replaces the BG71 aggregate (#2525): every row is now + * converted into the trip currency in the service before anything is added. + */ + async listMoneyRows(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_items') + .select(['id', 'category', 'total_price', 'currency', 'exchange_rate']) + .where('trip_id', '=', trip_id as number) + .orderBy('id', 'asc') + .execute(); + } + + /** + * BG89 (`ratesForTripTotals`) — `SELECT 1 FROM budget_items WHERE trip_id = ? + * AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND + * (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) LIMIT 1`. + */ + async hasUnfrozenForeign(trip_id: number | string, trip_currency: string): Promise { + const row = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .limit(1) + .executeTakeFirst(); + return row !== undefined; + } + + /** + * BG90's `budget_items` half (`freezeMissingRates`) — `SELECT UPPER(currency) + * AS cur FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL AND + * currency != '' AND UPPER(currency) != ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. + * The legacy statement UNIONed this with the settlements half; `DISTINCT` + * here stands in for the dedupe the UNION did, and the caller merges, dedupes + * and sorts the two lists. + */ + async listUnfrozenForeignCurrencies(trip_id: number | string, trip_currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_items') + .select((eb) => eb.fn('upper', ['currency']).as('cur')) + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.cur); + } + + /** BG91's `budget_items` half (`freezeMissingRates`) — `SELECT id FROM budget_items WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */ + async listUnfrozenIdsForCurrency(trip_id: number | string, currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.id); + } + + /** BG92's `budget_items` half (`freezeMissingRates`) — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */ + async freezeUnfrozenForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_items') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + } + + /** BG93 (`resyncReservationPrice`) — `SELECT total_price, currency FROM budget_items WHERE trip_id = ? AND reservation_id = ? ORDER BY id`, every expense linked to one booking (#2084). */ + async listLinkedToReservation(trip_id: number | string, reservation_id: number): Promise<{ total_price: number | null; currency: string | null }[]> { + return await this.kysely() + .selectFrom('budget_items') + .select(['total_price', 'currency']) + .where('trip_id', '=', trip_id as number) + .where('reservation_id', '=', reservation_id) + .orderBy('id', 'asc') + .execute(); + } + + /** BG17's `budget_items` half — `UPDATE budget_items SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`. */ + async pinCurrency(trip_id: number | string, prev_currency: string): Promise { + await this.kysely() + .updateTable('budget_items') + .set({ currency: prev_currency }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb.or([eb('currency', 'is', null), eb('currency', '=', '')])) + .execute(); + } + + /** BG18's `budget_items` half — `SELECT DISTINCT currency AS cur FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL`. */ + async listDistinctCurrencies(trip_id: number | string): Promise { + const rows = await this.kysely().selectFrom('budget_items').select('currency').distinct().where('trip_id', '=', trip_id as number).where('currency', 'is not', null).execute(); + return rows.map(r => r.currency as string); + } + + /** BG19's `budget_items` half — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`. */ + async setExchangeRateForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely().updateTable('budget_items').set({ exchange_rate }).where('trip_id', '=', trip_id as number).where('currency', '=', currency).execute(); + } + + // --------------------------------------------------------------------------- + // Receipts — budget's own view across `trip_files`/`file_links` (owned by + // Plan 3e Task 1, consumed here the same way `Reservations`/`Places` are + // consumed elsewhere: a cross-table Kysely read/write within the domain + // that needs the join, not a new method on the owning task's repository — + // Task 1's landed `TripFilesRepository`/`FileLinksRepository` do not carry + // the item-scoped shapes this domain needs (see the task report's + // deviation note). + // --------------------------------------------------------------------------- + + /** BG3 (`loadItemReceipts`) — `SELECT f.id, f.filename, f.original_name, f.file_size, f.mime_type, f.trip_id FROM trip_files f JOIN file_links fl ON fl.file_id = f.id WHERE f.deleted_at IS NULL AND fl.budget_item_id = ? ORDER BY f.created_at ASC`. */ + async listReceipts(budget_item_id: number | string): Promise { + return await this.kysely() + .selectFrom('trip_files as f') + .innerJoin('file_links as fl', 'fl.file_id', 'f.id') + .select(['f.id', 'f.filename', 'f.original_name', 'f.file_size', 'f.mime_type', 'f.trip_id']) + .where('f.deleted_at', 'is', null) + .where('fl.budget_item_id', '=', budget_item_id as number) + .orderBy('f.created_at', 'asc') + .execute(); + } + + /** BG13 (`listBudgetItems`'s receipt batch) — same projection as {@link listReceipts} plus `fl.budget_item_id`, `fl.budget_item_id IN (dynamic)`. */ + async listReceiptsForItems(budget_item_ids: number[]): Promise<(BudgetReceiptRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('trip_files as f') + .innerJoin('file_links as fl', 'fl.file_id', 'f.id') + .select(['f.id', 'f.filename', 'f.original_name', 'f.file_size', 'f.mime_type', 'f.trip_id', 'fl.budget_item_id']) + .where('f.deleted_at', 'is', null) + .where('fl.budget_item_id', 'in', budget_item_ids) + .orderBy('f.created_at', 'asc') + .execute(); + } + + /** BG7 (`unlinkReceipts`'s pre-read) — `SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id FROM file_links fl JOIN trip_files f ON f.id = fl.file_id WHERE fl.budget_item_id = ? AND f.deleted_at IS NULL`. */ + async listReceiptLinks(budget_item_id: number | string): Promise { + return await this.kysely() + .selectFrom('file_links as fl') + .innerJoin('trip_files as f', 'f.id', 'fl.file_id') + .select(['fl.id', 'fl.file_id', 'fl.reservation_id', 'fl.assignment_id', 'fl.place_id']) + .where('fl.budget_item_id', '=', budget_item_id as number) + .where('f.deleted_at', 'is', null) + .execute(); + } + + /** BG8 (`unlinkReceipts`) — `UPDATE file_links SET budget_item_id = NULL WHERE id = ?`. */ + async clearLinkBudgetRef(link_id: number): Promise { + await this.kysely().updateTable('file_links').set({ budget_item_id: null }).where('id', '=', link_id).execute(); + } + + /** BG9 (`unlinkReceipts`) — `DELETE FROM file_links WHERE id = ?`. */ + async deleteLink(link_id: number): Promise { + await this.kysely().deleteFrom('file_links').where('id', '=', link_id).execute(); + } + + /** BG29/BG44 — `SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL`, the receipt id's trip-scoping guard (security-critical). */ + async findTripFile(file_id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely() + .selectFrom('trip_files') + .select('id') + .where('id', '=', file_id) + .where('trip_id', '=', trip_id as number) + .where('deleted_at', 'is', null) + .executeTakeFirst(); + } + + /** BG28/BG45 — `INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)`. */ + async insertReceiptLink(file_id: number, budget_item_id: number | string): Promise { + await this.kysely<{ file_links: { file_id: number; budget_item_id: number } }>() + .insertInto('file_links') + .values({ file_id, budget_item_id: budget_item_id as number }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** BG46 (`updateBudgetItem`'s `adopt`) — `UPDATE file_links SET budget_item_id = ? WHERE id = ?`, re-parenting a spare link row instead of inserting a duplicate. */ + async adoptSpareLink(link_id: number, budget_item_id: number | string): Promise { + await this.kysely().updateTable('file_links').set({ budget_item_id: budget_item_id as number }).where('id', '=', link_id).execute(); + } + + /** BG47 — `SELECT 1 FROM file_links WHERE file_id = ? AND budget_item_id = ?`, the "already this item's receipt" short-circuit. */ + async linkAlreadyExists(file_id: number, budget_item_id: number | string): Promise { + const row = await this.kysely() + .selectFrom('file_links') + .select((eb) => eb.val(1).as('one')) + .where('file_id', '=', file_id) + .where('budget_item_id', '=', budget_item_id as number) + .executeTakeFirst(); + return row !== undefined; + } + + /** BG48 — `SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1` (`spare`), drives {@link adoptSpareLink}'s adopt-vs-insert branch. */ + async findSpareLink(file_id: number): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('file_links').select('id').where('file_id', '=', file_id).where('budget_item_id', 'is', null).limit(1).executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Survivors — AC41/PL15/PL18/PL22/RS48/49/51-54/UC5 (R7). Converted here so + // `accommodations.service.ts`/`places.service.ts`/`reservations.service.ts`/ + // `auth/user-cleanup.service.ts` gain a single repository method call in + // place of the raw statement, never a new SQL string of their own. + // --------------------------------------------------------------------------- + + /** + * AC41 (`deleteAccommodation`) — `SELECT id FROM budget_items WHERE reservation_id = ?`, + * every row: a booking can hold several expenses now (#2084), and the delete takes + * them all. + */ + async listIdsByReservation(reservation_id: number): Promise<{ id: number }[]> { + return await this.kysely().selectFrom('budget_items').select('id').where('reservation_id', '=', reservation_id).execute(); + } + + /** PL15 (`linkedExpenseIds`) — `SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (dynamic)`. */ + async listIdsForPlaces(trip_id: number | string, place_ids: (number | string)[]): Promise { + if (place_ids.length === 0) return []; + const rows = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('place_id', 'in', place_ids as number[]) + .execute(); + return rows.map(r => r.id); + } + + /** PL18/PL22 — `DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?`. */ + async deleteForPlace(trip_id: number | string, place_id: number | string): Promise { + await this.kysely().deleteFrom('budget_items').where('trip_id', '=', trip_id as number).where('place_id', '=', place_id as number).execute(); + } + + /** RS52/RS53 — `SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?`. */ + async findIdByReservationInTrip(trip_id: number | string, reservation_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('budget_items').select('id').where('trip_id', '=', trip_id as number).where('reservation_id', '=', reservation_id as number).executeTakeFirst(); + } + + /** + * RS48/RS51 and the RS52 guard (`linkedBudgetItems`) — `SELECT id, category + * FROM budget_items WHERE trip_id = ? AND reservation_id = ?`, every row: a + * booking can hold several expenses now (#2084), so the delete takes them + * all, the type change re-files each one still on the derived category, and + * the price field is left alone once more than one is linked. + */ + async listIdAndCategoryByReservation(trip_id: number | string, reservation_id: number | string): Promise<{ id: number; category: string }[]> { + return await this.kysely() + .selectFrom('budget_items') + .select(['id', 'category']) + .where('trip_id', '=', trip_id as number) + .where('reservation_id', '=', reservation_id as number) + .orderBy('id', 'asc') + .execute(); + } + + /** RS54 — `UPDATE budget_items SET reservation_id = ? WHERE id = ?` (binds the reservation id verbatim — a string in the legacy call site, matching parity). */ + async setReservationId(id: number | string, reservation_id: number | string): Promise { + await this.kysely().updateTable('budget_items').set({ reservation_id: reservation_id as number }).where('id', '=', id as number).execute(); + } + + /** UC5 (`UserCleanupService.cleanupUserReferences`) — `UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?`. */ + async clearPaidByUser(user_id: number): Promise { + await this.kysely().updateTable('budget_items').set({ paid_by_user_id: null }).where('paid_by_user_id', '=', user_id).execute(); + } +} diff --git a/server/src/db/repositories/BudgetSettlements.repository.ts b/server/src/db/repositories/BudgetSettlements.repository.ts new file mode 100644 index 000000000..6fe0e9bc8 --- /dev/null +++ b/server/src/db/repositories/BudgetSettlements.repository.ts @@ -0,0 +1,231 @@ +import type { BudgetSettlements } from '../entities/BudgetSettlements.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; + +/** A bare `budget_settlements` row — every scalar column, incl. the `persist(false)` relation mirrors. */ +export interface BudgetSettlementRow { + id: number; + trip_id: number; + from_user_id: number; + to_user_id: number; + amount: number; + created_at: string | null; + created_by_user_id: number | null; + currency: string | null; + exchange_rate: number; + settled_at: string | null; + note: string | null; +} + +const _budgetSettlementRowKeys: AssertRowKeys = true; + +/** `SETTLEMENT_SELECT`'s joined projection (BG76/77). */ +export interface BudgetSettlementWithUsersRow extends BudgetSettlementRow { + from_username: string; + from_avatar: string | null; + to_username: string; + to_avatar: string | null; +} + +interface BudgetSettlementsKyselyDB { + budget_settlements: BudgetSettlementRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** + * The insert-only shape — `id`/`created_at` are autoincrement/`DEFAULT + * CURRENT_TIMESTAMP` and omitted from `.values()` below (the legacy + * statement's own column list omits them too), matching the + * `FileLinksWriteKyselyDB`/`FileLinksRepository` precedent: a separate + * interface rather than making those two columns optional on + * {@link BudgetSettlementRow}, which would also loosen every SELECT + * method's inferred type. + */ +interface BudgetSettlementsWriteKyselyDB { + budget_settlements: { + trip_id: number; from_user_id: number; to_user_id: number; amount: number; + currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null; + }; +} + +/** + * `budget_settlements` — recorded settle-up transfers. Kysely throughout, + * same `persist(false)` reasoning as its sibling budget repositories. + */ +export class BudgetSettlementsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('budget_settlements as s') + .innerJoin('users as fu', 'fu.id', 's.from_user_id') + .innerJoin('users as tu', 'tu.id', 's.to_user_id') + .select([ + 's.id', 's.trip_id', 's.from_user_id', 's.to_user_id', 's.amount', 's.currency', 's.exchange_rate', + 's.created_at', 's.settled_at', 's.note', 's.created_by_user_id', + (eb) => eb.fn.coalesce('fu.display_name', 'fu.username').as('from_username'), 'fu.avatar as from_avatar', + (eb) => eb.fn.coalesce('tu.display_name', 'tu.username').as('to_username'), 'tu.avatar as to_avatar', + ]); + } + + /** BG76 (`listSettlements`) — `SETTLEMENT_SELECT WHERE s.trip_id = ? ORDER BY s.created_at DESC, s.id DESC`. */ + async listForTrip(trip_id: number | string): Promise { + return await this.joinedQuery() + .where('s.trip_id', '=', trip_id as number) + .orderBy('s.created_at', 'desc') + .orderBy('s.id', 'desc') + .execute(); + } + + /** + * BG77 (`getSettlement`) — `SETTLEMENT_SELECT WHERE s.trip_id = ? AND s.id + * = ?`, the targeted single-row read. `id: number` (Plan 4 Task 8b, U6 — + * the program's gate-level id parsing carry: `BudgetController + * .updateSettlement`/`.deleteSettlement` parse `:settlementId` once via + * `toRowId` and thread the number down; `trip_id` stays `number | string`, + * a separate, still-accepted carry). + */ + async findWithUsers(id: number, trip_id: number | string): Promise { + return await this.joinedQuery() + .where('s.trip_id', '=', trip_id as number) + .where('s.id', '=', id) + .executeTakeFirst(); + } + + /** BG79/BG81 — `SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?`, the trip-scoping existence guard. `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers}. */ + async findGuard(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely() + .selectFrom('budget_settlements') + .select('id') + .where('id', '=', id) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + } + + /** + * BG78 — `INSERT INTO budget_settlements (trip_id, from_user_id, + * to_user_id, amount, currency, exchange_rate, settled_at, note, + * created_by_user_id) VALUES (?×9)`. Named `insertSettlement`, not + * `insert` — collides with `TrekRepository`'s own same-named method. + */ + async insertSettlement(row: { + trip_id: number | string; from_user_id: number; to_user_id: number; amount: number; + currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null; + }): Promise { + const result = await this.kysely() + .insertInto('budget_settlements') + .values({ + trip_id: row.trip_id as number, from_user_id: row.from_user_id, to_user_id: row.to_user_id, amount: row.amount, + currency: row.currency, exchange_rate: row.exchange_rate, settled_at: row.settled_at, note: row.note, created_by_user_id: row.created_by_user_id, + }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * BG80 (`applySettlementUpdate`) — the 7-column presence-sentinel `UPDATE` + * (`from_user_id`/`to_user_id`/`amount` unconditional, `currency`/ + * `exchange_rate`/`settled_at`/`note` presence-gated) — R11's helper, the same + * shape `BudgetItemsRepository.update` (BG33) lands. + */ + /** `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers} (its one caller, `applySettlementUpdate`, is only reached with a `toRowId`-parsed id). */ + async update(id: number, write: { + from_user_id: number; to_user_id: number; amount: number; + currency?: [present: boolean, value: string | null]; + exchange_rate?: [present: boolean, value: number]; + settled_at?: [present: boolean, value: string | null]; + note?: [present: boolean, value: string | null]; + }): Promise { + const data = presenceSet<{ currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null }>({ + currency: write.currency, + exchange_rate: write.exchange_rate, + settled_at: write.settled_at, + note: write.note, + }); + await this.kysely() + .updateTable('budget_settlements') + .set({ from_user_id: write.from_user_id, to_user_id: write.to_user_id, amount: write.amount, ...data }) + .where('id', '=', id) + .execute(); + } + + /** BG82 — `DELETE FROM budget_settlements WHERE id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('budget_settlements').where('id', '=', id).execute(); + } + + /** BG17's `budget_settlements` half — `UPDATE budget_settlements SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`. */ + async pinCurrency(trip_id: number | string, prev_currency: string): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ currency: prev_currency }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb.or([eb('currency', 'is', null), eb('currency', '=', '')])) + .execute(); + } + + /** BG18's `budget_settlements` half — `SELECT DISTINCT currency AS cur FROM budget_settlements WHERE trip_id = ? AND currency IS NOT NULL`. */ + async listDistinctCurrencies(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select('currency') + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .execute(); + return rows.map(r => r.currency as string); + } + + /** + * BG90's `budget_settlements` half (`freezeMissingRates`) — `SELECT + * UPPER(currency) AS cur FROM budget_settlements WHERE trip_id = ? AND + * currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND + * (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. + * `DISTINCT` stands in for the dedupe the legacy UNION did. + */ + async listUnfrozenForeignCurrencies(trip_id: number | string, trip_currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select((eb) => eb.fn('upper', ['currency']).as('cur')) + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.cur); + } + + /** BG91's `budget_settlements` half (`freezeMissingRates`) — `SELECT id FROM budget_settlements WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */ + async listUnfrozenIdsForCurrency(trip_id: number | string, currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select('id') + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.id); + } + + /** BG92's `budget_settlements` half (`freezeMissingRates`) — `UPDATE budget_settlements SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */ + async freezeUnfrozenForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + } + + /** BG19's `budget_settlements` half — `UPDATE budget_settlements SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`. */ + async setExchangeRateForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where('currency', '=', currency) + .execute(); + } +} diff --git a/server/src/db/repositories/Categories.repository.ts b/server/src/db/repositories/Categories.repository.ts new file mode 100644 index 000000000..c2ac06d2a --- /dev/null +++ b/server/src/db/repositories/Categories.repository.ts @@ -0,0 +1,148 @@ +import type { Categories } from '../entities/Categories.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `categories` row as the API emits it. */ +export interface CategoryRow { + id: number; + name: string; + color: string | null; + icon: string | null; + user_id: number | null; + created_at: string | null; +} + +const _categoryRowKeys: AssertRowKeys = true; + +export class CategoriesRepository extends TrekRepository { + /** `SELECT * FROM categories ORDER BY name ASC` */ + async list(): Promise { + const categories = await this.find({}, { orderBy: { name: 'asc' } }); + return categories.map((category) => toRow(category) as CategoryRow); + } + + /** + * `share.service.ts:396` SH15 (`getSharedTripData`) — `SELECT * FROM + * categories`, no `ORDER BY` (unlike `list()` above, which adds `ORDER BY + * name ASC` for the authenticated `/api/categories` list — the legacy + * public-share statement never sorted). Unscoped by trip on purpose: "a + * shared global pool", the same statement the authed list reads from. + */ + async listAllUnordered(): Promise { + const categories = await this.find({}); + return categories.map((category) => toRow(category) as CategoryRow); + } + + /** + * PL33 (`PlacesService.importKmlPlaces`) — `SELECT id, name FROM + * categories`, the id/name-only projection the KML importer folds into + * `buildCategoryNameLookup` to resolve a folder name to a category id. No + * `ORDER BY` in the legacy statement — none added here. + */ + async listIdName(): Promise<{ id: number; name: string }[]> { + return this.qb('c') + .select(['c.id', 'c.name']) + .execute<{ id: number; name: string }[]>('all', false); + } + + /** + * `SELECT * FROM categories WHERE id = ?` — no owner filter. `user_id` + * being nullable makes a row *look* ownable, but this legacy statement + * never scoped by it: any id answers regardless of who created the row. + * Plain PK `findOne`, not `findOwnedOrGlobal` — that helper had no caller + * across categories or tags (neither site actually filters "owner or + * global") and was deleted rather than kept as dead code (Task 0 + * re-review, R6; see `_shared/owned-lookup.ts`'s docstring). + * + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B — supersedes the per-call + * option this repository used to pass itself, which superseded Plan 3a's + * I1 "`refresh: true` on every PK-only `findOne`"; see + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this is + * a "rows out" read, converted via `toRow` and discarded — the entity is + * loaded in a throwaway forked context and never lands in the request's + * identity map, so a raw write to this row earlier in the same request is + * always seen, and there is nothing here for a later `flush()` to find + * dirty. + */ + async findById(id: number): Promise { + const category = await this.findOne({ id }); + return category ? (toRow(category) as CategoryRow) : null; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)`. + * The `#6366f1`/`📍` defaulting stays in `CategoriesService` (D4's defaults + * rule) — this writes exactly what it is given. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 + * fix round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row. `insert` fires a single native + * INSERT with no side effects on the context/identity map, followed by a + * read-back (`findById`, `disableIdentityMap: true` by the base class's + * default): the returned row carries the generated `id` and the + * `defaultRaw` `created_at` the same way a legacy INSERT-then-reselect + * pair did. + */ + async createCategory(input: { name: string; color: string; icon: string; user_id: number }): Promise { + const id = await this.insert({ + name: input.name, + color: input.color, + icon: input.icon, + user: input.user_id, + }); + const inserted = await this.findById(id); + if (!inserted) { + throw new Error('createCategory: read-back after insert found no row'); + } + return inserted; + } + + /** + * `UPDATE categories SET name = COALESCE(?, name), color = COALESCE(?, + * color), icon = COALESCE(?, icon) WHERE id = ?`, re-selected. + * `nativeUpdate` only writes the keys present on `changes` — the + * entity-API equivalent of `COALESCE` against a bind parameter — so the + * caller must omit a field entirely (not pass it as `undefined`) to leave + * it untouched, which is how `CategoriesService` reproduces the legacy + * `|| null` coalesce-away-empty-string behaviour. Returns `null` when no + * row matches `id`, mirroring the legacy re-select's `undefined`. + * + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on this method: "convert them, don't + * complete the sweep on the read").** `nativeUpdate` writes the given + * columns directly, with no ORM change-set diff against an in-memory + * snapshot — that removes the whole class of staleness the old + * `refresh: true` lookup existed to guard against (Task 3 review, + * Important 1: an untouched column reading stale, a patch silently + * dropped because it happened to match a stale in-memory value, and a + * patch after `remove()` fabricating a row), not just the one B1 shape + * the rest of the program's `disableIdentityMap: true` ruling closes. When + * `changes` is non-empty, `nativeUpdate`'s own affected-row count IS the + * existence check (0 ⇒ `null`, no separate read); when `changes` is empty + * (nothing to write), a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh + * `findById` — a genuine rows-out read, not the entity `assign` just + * mutated in place. + */ + async patch(id: number, changes: { name?: string; color?: string; icon?: string }): Promise { + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + return this.findById(id); + } + + /** `DELETE FROM categories WHERE id = ?`. Returns the affected row count. */ + async remove(id: number): Promise { + return this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/CollabLinks.repository.ts b/server/src/db/repositories/CollabLinks.repository.ts new file mode 100644 index 000000000..bc32f05a4 --- /dev/null +++ b/server/src/db/repositories/CollabLinks.repository.ts @@ -0,0 +1,93 @@ +import type { CollabLinks } from '../entities/CollabLinks.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_links` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabLinkRow { + id: number; + trip_id: number; + user_id: number; + title: string; + url: string; + pinned: number | null; + created_at: string | null; + updated_at: string | null; +} + +const _collabLinkRowKeys: AssertRowKeys = true; + +/** CB36/CB38/CB41's joined projection — `l.*, u.username`. */ +export interface CollabLinkJoinRow extends CollabLinkRow { + username: string; +} + +interface CollabLinksKyselyDB { + collab_links: CollabLinkRow; + users: { id: number; username: string }; +} + +/** + * `collab_links` — pinned/shared trip links. Kysely throughout: `trip_id`/ + * `user_id` are `persist(false)` mirrors (the `TripFilesRepository` class + * docstring's trap), so a bare QueryBuilder `.select([...])` silently drops + * them. + */ +export class CollabLinksRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('collab_links as l') + .innerJoin('users as u', 'u.id', 'l.user_id') + .selectAll('l') + .select(['u.username']); + } + + /** + * CB36 (`listLinks`) — `SELECT l.*, u.username FROM collab_links l JOIN + * users u ON u.id = l.user_id WHERE l.trip_id = ? ORDER BY l.pinned DESC, + * l.created_at DESC`. `trip_id: number`: the service resolves the route's + * `string | number` id via `toRowId` before calling (rule 15). + */ + async listForTrip(trip_id: number): Promise { + return await this.joinedQuery().where('l.trip_id', '=', trip_id).orderBy('l.pinned', 'desc').orderBy('l.created_at', 'desc').execute(); + } + + /** CB39 (`updateLink`'s trip-scoping guard) — `SELECT * FROM collab_links WHERE id = ? AND trip_id = ?`. Full row: the falsy-coercion update (CB40) needs the pre-image to fall back onto. */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_links').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB37 (`createLink`) — `INSERT INTO collab_links (trip_id, user_id, title, url, pinned) VALUES (?×5)`. The service trims `title`/`url` before calling (matching the legacy inline `.trim()`). Returns the new row's id. */ + async insertLink(row: { trip_id: number | string; user_id: number; title: string; url: string; pinned: number }): Promise { + return await this.insert({ trip: row.trip_id, user: row.user_id, title: row.title, url: row.url, pinned: row.pinned }); + } + + /** + * CB40 (`updateLink`) — `UPDATE collab_links SET title = ?, url = ?, + * pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. + * The ONE outlier in this cluster: `|| existing.x` falsy-coercion, NOT the + * presence-sentinel `CASE` shape `CollabNotesRepository.update`/`CB11` + * uses (task-5-brief.md's explicit instruction — kept as its own distinct + * shape). The SERVICE resolves `data.title?.trim() || existing.title` / + * `data.url?.trim() || existing.url` / `data.pinned === undefined ? + * existing.pinned : (data.pinned ? 1 : 0)` to final values first (same + * read-merge-write split as {@link CollabNotesRepository.update}) — this + * method just writes them, scoped by BOTH `id` AND `trip_id` (unlike + * CB11's notes update, which the legacy only scopes by `id`). + */ + async update(id: number, trip_id: number, write: { title: string; url: string; pinned: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id, trip: trip_id }, { title: write.title, url: write.url, pinned: write.pinned, updated_at: currentTimestamp(platform) }); + } + + /** CB38/CB41 (`createLink`'s post-insert re-select, `updateLink`'s post-write re-select) — `SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?`, same text at both call sites. */ + async findWithUser(id: number): Promise { + return await this.joinedQuery().where('l.id', '=', id).executeTakeFirst(); + } + + /** CB42 (`deleteLink`) — `DELETE FROM collab_links WHERE id = ? AND trip_id = ?`, returns whether a row was actually removed (legacy `.changes > 0`). */ + async deleteScoped(id: number, trip_id: number): Promise { + const affected = await this.nativeDelete({ id, trip: trip_id }); + return affected > 0; + } +} diff --git a/server/src/db/repositories/CollabMessageReactions.repository.ts b/server/src/db/repositories/CollabMessageReactions.repository.ts new file mode 100644 index 000000000..d0a65aa08 --- /dev/null +++ b/server/src/db/repositories/CollabMessageReactions.repository.ts @@ -0,0 +1,74 @@ +import type { CollabMessageReactions } from '../entities/CollabMessageReactions.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** CB1/CB46's joined projection — `r.emoji, r.user_id, u.username` (CB46 additionally carries `message_id`, the batch key). */ +export interface ReactionJoinRow { + message_id: number; + emoji: string; + user_id: number; + username: string; +} + +export class CollabMessageReactionsRepository extends TrekRepository { + /** + * CB1 (`CollabService.loadReactions`) — `SELECT r.emoji, r.user_id, + * u.username FROM collab_message_reactions r JOIN users u ON r.user_id = + * u.id WHERE r.message_id = ?`. `r.user_id`/`r.message_id` are + * `persist(false)` mirrors of the `message`/`user` relations — selecting + * the relation property (not the mirror) is what actually hydrates it + * (`PlaceRatingsRepository.listForPlaces`'s docstring, the same trap). + */ + async listForMessage(message_id: number): Promise[]> { + return this.qb('r') + .join('r.user', 'u') + .select(['r.emoji', 'u.id as user_id', 'u.username']) + .where({ message: message_id }) + .execute[]>('all', false); + } + + /** + * CB46 (`CollabService.listMessages`'s batch reactions) — `SELECT + * r.message_id, r.emoji, r.user_id, u.username FROM collab_message_reactions + * r JOIN users u ON r.user_id = u.id WHERE r.message_id IN (dynamic)`. + * Empty `message_ids` short-circuits, matching the service's own guard + * (`if (msgIds.length > 0)`). + */ + async listForMessages(message_ids: number[]): Promise { + if (message_ids.length === 0) return []; + return this.qb('r') + .join('r.user', 'u') + .select(['r.message', 'r.emoji', 'u.id as user_id', 'u.username']) + .where({ 'r.message': { $in: message_ids } }) + .execute('all', false); + } + + /** + * CB3 (`CollabService.reactMessage`'s toggle check) — `SELECT id FROM + * collab_message_reactions WHERE message_id = ? AND user_id = ? AND + * emoji = ?`. Named `findReaction`, not `find`: `find` is `EntityRepository`'s + * own base method (a different, incompatible signature) and a same-named + * override does not typecheck. + */ + async findReaction(message_id: number, user_id: number, emoji: string): Promise<{ id: number } | null> { + return this.findOne({ message: message_id, user: user_id, emoji }, { fields: ['id'] }); + } + + /** + * CB4 (`reactMessage`'s toggle-off half) — `DELETE FROM + * collab_message_reactions WHERE id = ?`. Deliberately NOT wrapped in a + * transaction with {@link insertReaction}/{@link find}: the legacy + * check-then-toggle (CB3→CB4/CB5) has no `uow.transactional` either — a + * duplicate/missing reaction under a race is cosmetic, not the + * data-integrity class of defect the R2/`toggleMemberPaid` fixes address + * (task-5-brief.md's own ruling; left as-is rather than "fixed" + * unilaterally). + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** CB5 (`reactMessage`'s toggle-on half) — `INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)`. */ + async insertReaction(message_id: number, user_id: number, emoji: string): Promise { + await this.insert({ message: message_id, user: user_id, emoji }); + } +} diff --git a/server/src/db/repositories/CollabMessages.repository.ts b/server/src/db/repositories/CollabMessages.repository.ts new file mode 100644 index 000000000..fc74dc207 --- /dev/null +++ b/server/src/db/repositories/CollabMessages.repository.ts @@ -0,0 +1,229 @@ +import type { CollabMessages } from '../entities/CollabMessages.entity'; +import type { TripFileRow } from './TripFiles.repository'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_messages` row — every scalar column of the entity, incl. the three `persist(false)` mirrors (`trip_id`, `user_id`, `reply_to`). */ +export interface CollabMessageRow { + id: number; + trip_id: number; + user_id: number; + text: string; + reply_to: number | null; + created_at: string | null; + deleted: number | null; +} + +const _collabMessageRowKeys: AssertRowKeys = true; + +/** CB45/CB50's joined projection — `m.*, u.username, u.avatar, reply_text, reply_username`. */ +export interface CollabMessageJoinRow extends CollabMessageRow { + username: string; + avatar: string | null; + reply_text: string | null; + reply_username: string | null; +} + +/** {@link CollabMessagesRepository.listPublicForShare}'s projection (SH16) — `m.*, u.username, u.avatar` only. */ +export interface SharePublicCollabMessageRow extends CollabMessageRow { + username: string; + avatar: string | null; +} + +interface CollabMessagesKyselyDB { + collab_messages: CollabMessageRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** Only the `trip_files` columns the message-attachment methods below touch — the `CollabNotesRepository` class docstring explains why they live on the OWNING collab entity's repository rather than on `TripFilesRepository`. */ +interface MessageAttachmentsKyselyDB { + trip_files: TripFileRow; +} + +/** CB43's narrow attachment projection (`formatMessage`'s hydration read). `message_id` keeps `TripFileRow`'s own nullable typing even though the WHERE clause below always matches a non-null value — no narrowing cast. */ +export interface MessageAttachmentRow { + id: number; + trip_id: number; + message_id: number | null; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; +} + +/** CB49's insert-only shape — every OTHER `trip_files` column is autoincrement/nullable-defaulted and omitted, matching the legacy statement's own seven-column list (the `NoteAttachmentInsertKyselyDB` precedent, `CollabNotes.repository.ts`). */ +interface MessageAttachmentInsertKyselyDB { + trip_files: { + trip_id: number | string; + message_id: number | string; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + uploaded_by: number; + }; +} + +/** + * `collab_messages` — trip chat, plus (second half of this class) the + * `trip_files` rows a message owns as attachments. Kysely throughout for + * `collab_messages` itself: `trip_id`/`user_id`/`reply_to` are + * `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap). + */ +export class CollabMessagesRepository extends TrekRepository { + /** + * CB45/CB50's shared shape — `m.*, u.username, u.avatar, CASE WHEN + * rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text, ru.username AS + * reply_username FROM collab_messages m JOIN users u ON m.user_id = u.id + * LEFT JOIN collab_messages rm ON m.reply_to = rm.id LEFT JOIN users ru ON + * rm.user_id = ru.id`. A genuine self-join (`rm`/`ru` are second aliases + * of `collab_messages`/`users`), expressed as a second Kysely alias rather + * than the ORM's `replyToRef` relation: `CollabMessages.entity.ts` DOES + * have a real `replyToRef` manyToOne(CollabMessages) relation (Task 0's + * dialect finding, `progress.md`), but populating it through the + * QueryBuilder would hydrate a full `CollabMessages` entity for the reply + * (and a SEPARATE query/join for `ru.username`, since `replyToRef` doesn't + * carry the reply's own author's username) rather than the two flat scalar + * columns (`reply_text`, `reply_username`) this statement actually + * projects — the `Reservations.repository.ts#listUpcomingForUser` + * self-join precedent (`reservations as r2`) is the exact same shape: + * Kysely, not a relation walk, for a flat projected self-join. + */ + private joinedQuery() { + return this.kysely() + .selectFrom('collab_messages as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .leftJoin('collab_messages as rm', 'rm.id', 'm.reply_to') + .leftJoin('users as ru', 'ru.id', 'rm.user_id') + .selectAll('m') + .select((eb) => [ + 'u.username', + 'u.avatar', + eb.case().when('rm.deleted', '=', 1).then('').else(eb.ref('rm.text')).end().as('reply_text'), + 'ru.username as reply_username', + ]); + } + + /** + * CB45 (`listMessages`) — the joined shape above, `WHERE m.trip_id = ?[ + * AND m.id < ?] ORDER BY m.id DESC LIMIT 100`. `before` undefined omits + * the cursor filter entirely (matching the legacy's conditional extra + * param), not `>= 0`/`null` — the same shape the legacy ternary between + * two whole query strings expressed. + */ + async listForTrip(trip_id: number, before?: number): Promise { + let q = this.joinedQuery().where('m.trip_id', '=', trip_id); + if (before !== undefined) q = q.where('m.id', '<', before); + return await q.orderBy('m.id', 'desc').limit(100).execute(); + } + + /** CB50 (`createMessage`'s post-tx re-select) — the joined shape above, `WHERE m.id = ?`, no trip filter (the caller just wrote this exact id inside the transaction). */ + async findWithReply(id: number): Promise { + return await this.joinedQuery().where('m.id', '=', id).executeTakeFirst(); + } + + /** + * `share.service.ts:400` SH16 (`getSharedTripData`'s share_collab read) — + * `SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u + * ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY + * m.created_at`. A narrower projection than {@link joinedQuery} above (no + * `reply_text`/`reply_username` — a public share viewer never resolves a + * reply thread through this read) and a different order/shape than + * `listForTrip` (ascending, no cursor, no `LIMIT`). + */ + async listPublicForShare(trip_id: number): Promise { + return await this.kysely() + .selectFrom('collab_messages as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .selectAll('m') + .select(['u.username', 'u.avatar']) + .where('m.trip_id', '=', trip_id) + .where('m.deleted', '=', 0) + .orderBy('m.created_at', 'asc') + .execute(); + } + + /** + * CB2/CB51 (`reactMessage`'s existence guard, `deleteMessage`'s + * trip-scoping + owner-check read) — `SELECT * FROM collab_messages WHERE + * id = ? AND trip_id = ?` (CB2's own text is `SELECT id FROM ...`, a + * narrower projection of the SAME WHERE — reading the full row for both + * call sites is behaviourally identical since CB2 only tests truthiness). + * `SELECT *` here has NO `username` column (`collab_messages` alone, no + * join) — `deleteMessage`'s legacy `return { username: message.username + * }` reads a column this statement never selected; see the `CollabService + * .deleteMessage` docstring for how that pre-existing gap is preserved. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_messages').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB47 (`createMessage`'s reply-target validation) — `SELECT id FROM collab_messages WHERE id = ? AND trip_id = ? AND deleted = 0`. A soft-deleted message is gone as far as anyone replying is concerned. */ + async findActiveInTrip(id: number, trip_id: number): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('collab_messages').select(['id']).where('id', '=', id).where('trip_id', '=', trip_id).where('deleted', '=', 0).executeTakeFirst(); + } + + /** CB44 (`countMessages`) — `SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?`. */ + async countForTrip(trip_id: number): Promise { + return await this.count({ trip: trip_id }); + } + + /** CB48 (`createMessage`, inside its transaction) — `INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?×4)`. Returns the new row's id. */ + async insertMessage(trip_id: number | string, user_id: number, text: string, reply_to: number | null): Promise { + return await this.insert({ trip: trip_id, user: user_id, text, reply_to }); + } + + /** CB54 (`deleteMessage`, inside its transaction) — `UPDATE collab_messages SET deleted = 1 WHERE id = ?`, soft delete — the row survives for the placeholder. */ + async softDelete(id: number): Promise { + await this.nativeUpdate({ id }, { deleted: 1 }); + } + + // ------------------------------------------------------------------------- + // Message attachments (`trip_files` scoped by `message_id`) — see + // `CollabNotesRepository`'s class docstring for why these live here + // rather than on `TripFilesRepository`. + // ------------------------------------------------------------------------- + + /** CB43 (`formatMessage`'s hydration read) — `SELECT id, trip_id, message_id, filename, original_name, file_size, mime_type FROM trip_files WHERE message_id = ? AND trip_id = ? AND deleted_at IS NULL ORDER BY id ASC`. */ + async listAttachmentsForMessage(message_id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'trip_id', 'message_id', 'filename', 'original_name', 'file_size', 'mime_type']) + .where('message_id', '=', message_id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .orderBy('id', 'asc') + .execute(); + } + + /** + * CB49 (`createMessage`, inside its transaction, once per uploaded file) — + * `INSERT INTO trip_files (trip_id, message_id, filename, original_name, + * file_size, mime_type, uploaded_by) VALUES (?×7)`. + */ + async insertAttachmentForMessage(row: { trip_id: number | string; message_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string; uploaded_by: number }): Promise { + await this.kysely() + .insertInto('trip_files') + .values({ + trip_id: row.trip_id, + message_id: row.message_id, + filename: row.filename, + original_name: row.original_name, + file_size: row.file_size, + mime_type: row.mime_type, + uploaded_by: row.uploaded_by, + }) + .execute(); + } + + /** CB52 (`deleteMessage`'s pre-tx storage-cleanup list) — `SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?`, run BEFORE the transaction (the legacy ordering). */ + async listFilenamesForMessage(message_id: number, trip_id: number): Promise<{ filename: string }[]> { + return await this.kysely().selectFrom('trip_files').select(['filename']).where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute(); + } + + /** CB53 (`deleteMessage`, inside its transaction, before CB54) — `DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?`. */ + async deleteAttachmentsForMessage(message_id: number, trip_id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute(); + } +} diff --git a/server/src/db/repositories/CollabNotes.repository.ts b/server/src/db/repositories/CollabNotes.repository.ts new file mode 100644 index 000000000..4a67f5ba2 --- /dev/null +++ b/server/src/db/repositories/CollabNotes.repository.ts @@ -0,0 +1,256 @@ +import type { CollabNotes } from '../entities/CollabNotes.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import type { TripFileRow } from './TripFiles.repository'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_notes` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabNoteRow { + id: number; + trip_id: number; + user_id: number; + category: string | null; + title: string; + content: string | null; + color: string | null; + pinned: number | null; + created_at: string | null; + updated_at: string | null; + website: string | null; +} + +const _collabNoteRowKeys: AssertRowKeys = true; + +/** CB7/CB9/CB12/CB20's joined projection — `n.*, u.username, u.avatar`. */ +export interface CollabNoteJoinRow extends CollabNoteRow { + username: string; + avatar: string | null; +} + +/** CB6's narrow attachment projection (`formatNote`'s hydration read). */ +export interface NoteAttachmentRow { + id: number; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; +} + +interface CollabNotesKyselyDB { + collab_notes: CollabNoteRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** Only the `trip_files` columns the note-attachment methods below touch — see the class docstring's second half. */ +interface NoteAttachmentsKyselyDB { + trip_files: TripFileRow; +} + +/** + * CB18's insert-only shape — `id`/`place_id`/`reservation_id`/`description`/ + * `created_at`/`uploaded_by`/`starred`/`deleted_at`/`message_id` are + * autoincrement/nullable-defaulted and omitted from `.values()` below (the + * legacy statement's own six-column list omits them too), so a SEPARATE + * interface from {@link NoteAttachmentsKyselyDB} rather than widening that + * one's required columns (the `TripAlbumLinksInsertKyselyDB` / + * `FileLinksWriteKyselyDB` precedent: one purpose-shaped Kysely interface per + * statement). + */ +interface NoteAttachmentInsertKyselyDB { + trip_files: { + trip_id: number | string; + note_id: number | string; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + }; +} + +/** + * `collab_notes` — shared trip notes, plus (second half of this class) the + * `trip_files` rows a note owns as attachments. + * + * Kysely throughout for `collab_notes` itself: `trip_id`/`user_id` are + * `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap), so a bare QueryBuilder `.select([...])` silently drops them — + * `selectAll()`/a typed column list reads the real DB columns directly. + * + * **Why the note-attachment methods (CB6/CB14/CB15/CB18/CB19/CB21/CB22) live + * here instead of on `TripFilesRepository`.** The inventory's own proposal + * (`plan3e-sql-inventory.md` §4's "Proposed repositories" note) expected + * `TripFilesRepository` to grow `listForNote`/`deleteForNote`/`findScoped`/ + * `listForMessage`/`insertForMessage`/`deleteForMessage`. Task 1 (files) + * landed first (`399ca58a7`) and did NOT add those — its report lists only + * `findInTrip`/`findDeletedInTrip`/`listForTrip`/`findByIdWithJoins`/ + * `listTrashed`/`insertFile`/`updateFile`/`setStarred`/`softDelete`/ + * `restore`/`deleteById`/`deleteMany`, none scoped by `note_id`/`message_id`. + * `TripFiles.repository.ts` is Task 1's named file and this task's brief + * says "you read TripFilesRepository..., you do not edit them" — so rather + * than editing a landed, other-task-owned file, the note-scoped queries are + * expressed here (and the message-scoped twins on `CollabMessages + * .repository.ts`) via `this.kysely()` against the `trip_files` table + * directly, the same "a repository touches more than its own table via + * Kysely" shape `TripFilesRepository.joinedQuery` itself uses for + * `reservations`/`users`. `TripFileRow`'s TYPE is imported (read-only) from + * `TripFiles.repository.ts` for column-shape parity — no write to that file. + * Flagged in the task-5 report as a deviation from the inventory's proposal, + * not a defect. + */ +export class CollabNotesRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('collab_notes as n') + .innerJoin('users as u', 'u.id', 'n.user_id') + .selectAll('n') + .select(['u.username', 'u.avatar']); + } + + /** + * CB7 (`CollabService.listNotes`) — `SELECT n.*, u.username, u.avatar FROM + * collab_notes n JOIN users u ON n.user_id = u.id WHERE n.trip_id = ? + * ORDER BY n.pinned DESC, n.updated_at DESC`. `trip_id: number`: the + * service resolves the route's `string | number` id via `toRowId` before + * calling (rule 15), same as every other read below — a malformed id + * short-circuits to the "not found"/empty legacy result without reaching + * this query at all. + */ + async listForTrip(trip_id: number): Promise { + return await this.joinedQuery().where('n.trip_id', '=', trip_id).orderBy('n.pinned', 'desc').orderBy('n.updated_at', 'desc').execute(); + } + + /** CB10/CB13/CB17 (`updateNote`/`deleteNote`/`addNoteFile`'s shared trip-scoping guard) — `SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?`, same text at all three call sites. */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_notes').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB9/CB12 (`createNote`'s post-insert re-select, `updateNote`'s post-write re-select) — `SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?`, no trip filter (the caller already knows `id` is in-scope, having just written it). */ + async findWithUser(id: number): Promise { + return await this.joinedQuery().where('n.id', '=', id).executeTakeFirst(); + } + + /** CB20 (`getFormattedNoteById`) — same shape as {@link findWithUser} plus the trip scope (`n.trip_id = ?`) the legacy statement adds at this one call site (the post-migration hardening the class docstring on `CollabService` describes). */ + async findWithUserInTrip(id: number, trip_id: number): Promise { + return await this.joinedQuery().where('n.id', '=', id).where('n.trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB8 (`createNote`) — `INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) VALUES (?×8)`. The service resolves the `|| null`/`|| 'General'`/`|| '#6366f1'` defaults before calling (D2's split). Returns the new row's id. */ + async insertNote(row: { trip_id: number | string; user_id: number; title: string; content: string | null; category: string; color: string; website: string | null; pinned: number }): Promise { + return await this.insert({ + trip: row.trip_id, + user: row.user_id, + title: row.title, + content: row.content, + category: row.category, + color: row.color, + website: row.website, + pinned: row.pinned, + }); + } + + /** + * CB11 (`updateNote`) — the 6-column `UPDATE collab_notes SET title = + * COALESCE(?, title), content = CASE WHEN ? THEN ? ELSE content END, + * category = COALESCE(?, category), color = COALESCE(?, color), pinned = + * CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, website = CASE WHEN ? + * THEN ? ELSE website END, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. + * + * The `Reservations.repository.ts#updateReservation` / `PlacesRepository + * #updatePlace` precedent (RS41/PL11): the SERVICE resolves every + * COALESCE-keep and presence-sentinel column to its FINAL value first (it + * already holds the pre-image via `findInTrip`'s CB10 read every caller + * makes) — this method just writes what it is handed, no SQL-side + * COALESCE/CASE. R11 names a shared "presence-sentinel helper" other Plan + * 3e tasks (2/3/4) should copy; at the time this task ran, Task 2 (budget) + * had not landed yet (`progress.md`'s task table), so there was no landed + * helper to copy — this repeats the ALREADY-LANDED RS41/PL11 read-merge- + * write shape from Plans 3c/3d directly instead of inventing a new raw-SQL + * `CASE` helper, and this class docstring records that choice for + * whichever task reads it next. + */ + async update(id: number, write: { title: string; content: string | null; category: string | null; color: string | null; pinned: number | null; website: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + title: write.title, + content: write.content, + category: write.category, + color: write.color, + pinned: write.pinned, + website: write.website, + updated_at: currentTimestamp(platform), + }); + } + + /** CB16 (`deleteNote`, inside its transaction, after CB15's attachment sweep) — `DELETE FROM collab_notes WHERE id = ?`. */ + async delete(id: number): Promise { + await this.nativeDelete({ id }); + } + + // ------------------------------------------------------------------------- + // Note attachments (`trip_files` scoped by `note_id`) — see the class + // docstring's second half for why these live here rather than on + // `TripFilesRepository`. + // ------------------------------------------------------------------------- + + /** CB6 (`formatNote`'s hydration read) — `SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?`. */ + async listAttachmentsForNote(note_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'filename', 'original_name', 'file_size', 'mime_type']) + .where('note_id', '=', note_id) + .execute(); + } + + /** CB14 (`deleteNote`'s pre-tx storage-cleanup list) — `SELECT id, filename FROM trip_files WHERE note_id = ?`, run BEFORE the transaction (the legacy ordering: the storage deletes happen outside/before the DB transaction). */ + async listFilenamesForNote(note_id: number): Promise<{ id: number; filename: string }[]> { + return await this.kysely().selectFrom('trip_files').select(['id', 'filename']).where('note_id', '=', note_id).execute(); + } + + /** CB15 (`deleteNote`, inside its transaction, before CB16) — `DELETE FROM trip_files WHERE note_id = ?`. */ + async deleteAttachmentsForNote(note_id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('note_id', '=', note_id).execute(); + } + + /** + * CB18 (`addNoteFile`'s insert half) — `INSERT INTO trip_files (trip_id, + * note_id, filename, original_name, file_size, mime_type) VALUES (?×6)`. + * Returns the new row's id (Kysely's `InsertResult.insertId`, this + * dialect's `lastInsertRowid` equivalent) for CB19's re-select. + */ + async insertAttachmentForNote(row: { trip_id: number | string; note_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string }): Promise { + const result = await this.kysely() + .insertInto('trip_files') + .values({ trip_id: row.trip_id, note_id: row.note_id, filename: row.filename, original_name: row.original_name, file_size: row.file_size, mime_type: row.mime_type }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** CB19 (`addNoteFile`'s re-select, immediately after {@link insertAttachmentForNote}) — `SELECT * FROM trip_files WHERE id = ?`, unscoped (the caller just wrote this exact id). */ + async findAttachmentById(id: number): Promise { + return await this.kysely().selectFrom('trip_files').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** + * CB21 (`deleteNoteFile`) — `SELECT * FROM trip_files WHERE id = ? AND + * note_id = ? AND trip_id = ?`. **IDOR guard, security-critical**: all + * three scoping columns in ONE query, never split into separate existence + * checks a caller could satisfy independently (the legacy doc comment's + * own warning, task-5-brief.md's explicit instruction) — this ties the + * deleted object to the URL's `:tripId` the controller already + * access-checked, not just to a note/file id an attacker can enumerate. + */ + async findScopedForNote(id: number, note_id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('note_id', '=', note_id) + .where('trip_id', '=', trip_id) + .executeTakeFirst(); + } + + /** CB22 (`deleteNoteFile`, after the IDOR-guarded read and the storage delete) — `DELETE FROM trip_files WHERE id = ?`. */ + async deleteAttachmentById(id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('id', '=', id).execute(); + } +} diff --git a/server/src/db/repositories/CollabPollVotes.repository.ts b/server/src/db/repositories/CollabPollVotes.repository.ts new file mode 100644 index 000000000..674f5e503 --- /dev/null +++ b/server/src/db/repositories/CollabPollVotes.repository.ts @@ -0,0 +1,51 @@ +import type { CollabPollVotes } from '../entities/CollabPollVotes.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** CB24's joined projection — `v.option_index, v.user_id, u.username, u.avatar`. */ +export interface PollVoteJoinRow { + option_index: number; + user_id: number; + username: string; + avatar: string | null; +} + +export class CollabPollVotesRepository extends TrekRepository { + /** + * CB24 (`getPollWithVotes`'s votes half) — `SELECT v.option_index, + * v.user_id, u.username, u.avatar FROM collab_poll_votes v JOIN users u ON + * v.user_id = u.id WHERE v.poll_id = ?`. + */ + async listForPoll(poll_id: number): Promise { + return this.qb('v') + .join('v.user', 'u') + .select(['v.option_index', 'u.id as user_id', 'u.username', 'u.avatar']) + .where({ poll: poll_id }) + .execute('all', false); + } + + /** + * CB28 (`votePoll`'s existing-vote check) — `SELECT id FROM + * collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = + * ?`. Named `findVote`, not `find`: `find` is `EntityRepository`'s own + * base method (a different, incompatible signature) and a same-named + * override does not typecheck. + */ + async findVote(poll_id: number, user_id: number, option_index: number): Promise<{ id: number } | null> { + return this.findOne({ poll: poll_id, user: user_id, option_index }, { fields: ['id'] }); + } + + /** CB29 (`votePoll`'s toggle-off half, NOT inside the transaction — matching the legacy: only the multi-vote-clear branch below is transacted) — `DELETE FROM collab_poll_votes WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** CB30 (`votePoll`'s single-choice clear, inside the transaction, only when `!poll.multiple`) — `DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?`. */ + async deleteForUser(poll_id: number, user_id: number): Promise { + await this.nativeDelete({ poll: poll_id, user: user_id }); + } + + /** CB31 (`votePoll`, inside the transaction) — `INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)`, the entity's own composite-unique `(poll, user, option_index)`. */ + async insertVote(poll_id: number, user_id: number, option_index: number): Promise { + await this.insert({ poll: poll_id, user: user_id, option_index }); + } +} diff --git a/server/src/db/repositories/CollabPolls.repository.ts b/server/src/db/repositories/CollabPolls.repository.ts new file mode 100644 index 000000000..dfa1434d2 --- /dev/null +++ b/server/src/db/repositories/CollabPolls.repository.ts @@ -0,0 +1,77 @@ +import type { CollabPolls } from '../entities/CollabPolls.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_polls` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabPollRow { + id: number; + trip_id: number; + user_id: number; + question: string; + options: string; + multiple: number | null; + closed: number | null; + deadline: string | null; + created_at: string | null; +} + +const _collabPollRowKeys: AssertRowKeys = true; + +/** CB23's joined projection — `p.*, u.username, u.avatar`. */ +export interface CollabPollJoinRow extends CollabPollRow { + username: string; + avatar: string | null; +} + +interface CollabPollsKyselyDB { + collab_polls: CollabPollRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** + * `collab_polls` — decision polls. Kysely throughout: `trip_id`/`user_id` + * are `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap), so a bare QueryBuilder `.select([...])` silently drops them. + */ +export class CollabPollsRepository extends TrekRepository { + /** CB23 (`getPollWithVotes`'s poll half) — `SELECT p.*, u.username, u.avatar FROM collab_polls p JOIN users u ON p.user_id = u.id WHERE p.id = ?`. `id: number`: the service resolves the route's `string | number` id via `toRowId` before calling (rule 15). */ + async findWithUser(id: number): Promise { + return await this.kysely() + .selectFrom('collab_polls as p') + .innerJoin('users as u', 'u.id', 'p.user_id') + .selectAll('p') + .select(['u.username', 'u.avatar']) + .where('p.id', '=', id) + .executeTakeFirst(); + } + + /** CB25 (`listPolls`) — `SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC`. */ + async listIdsForTrip(trip_id: number): Promise { + const rows = await this.kysely().selectFrom('collab_polls').select(['id']).where('trip_id', '=', trip_id).orderBy('created_at', 'desc').execute(); + return rows.map((r) => r.id); + } + + /** + * CB27/CB32/CB34 (`votePoll`/`closePoll`/`deletePoll`'s shared trip-scoping + * guard) — `SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?`, same + * text at all three call sites. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_polls').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB26 (`createPoll`) — `INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) VALUES (?×6)`. Returns the new row's id. */ + async insertPoll(row: { trip_id: number | string; user_id: number; question: string; options: string; multiple: number; deadline: string | null }): Promise { + return await this.insert({ trip: row.trip_id, user: row.user_id, question: row.question, options: row.options, multiple: row.multiple, deadline: row.deadline }); + } + + /** CB33 (`closePoll`) — `UPDATE collab_polls SET closed = 1 WHERE id = ?`. */ + async close(id: number): Promise { + await this.nativeUpdate({ id }, { closed: 1 }); + } + + /** CB35 (`deletePoll`) — `DELETE FROM collab_polls WHERE id = ?`. */ + async delete(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/CollectionLabels.repository.ts b/server/src/db/repositories/CollectionLabels.repository.ts new file mode 100644 index 000000000..37e58b272 --- /dev/null +++ b/server/src/db/repositories/CollectionLabels.repository.ts @@ -0,0 +1,144 @@ +import type { CollectionLabels } from '../entities/CollectionLabels.entity'; +import { lower, lowerParam } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `collection_labels` — `loadLabelsByCollection`'s (CL7) shape. */ +export interface CollectionLabelRow { + id: number; + collection_id: number; + name: string; + color: string | null; + sort_order: number | null; +} + +interface CollectionLabelsKyselyDB { + collection_labels: { + id: number; + collection_id: number; + name: string; + color: string | null; + sort_order: number | null; + created_at: string | null; + }; +} + +/** CL20's own `INSERT INTO collection_labels (collection_id, name, color, sort_order)` column list, exactly — no `id`/`created_at`, the `BucketListInsertKyselyDB` narrower-insert-interface precedent. */ +interface CollectionLabelsInsertKyselyDB { + collection_labels: { collection_id: number; name: string; color: string; sort_order: number }; +} + +/** + * `collection_labels` — Plan 3h Task 1's first cut. Owns Part A's own + * label reads/insert (CL7, `labelIdsForFile`'s CL18/CL19, `insertImportedLabel`'s + * CL20). Task 2 extends this repository with `createLabel`/`updateLabel`/ + * `deleteLabel`/`collectionIdOfLabel`/`getLabelById` (CL71-77, beyond this + * task's CL1-36 range). + */ +export class CollectionLabelsRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + /** CL7 (`loadLabelsByCollection`) — `SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id=? ORDER BY sort_order, id`. */ + async listByCollection(collectionId: number): Promise { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'collection_id', 'name', 'color', 'sort_order']) + .where('collection_id', '=', collectionId) + .orderBy('sort_order') + .orderBy('id') + .execute(); + } + + /** CL18 (`labelIdsForFile`) — `SELECT id, name FROM collection_labels WHERE collection_id=?`. */ + async idNameByCollection(collectionId: number): Promise<{ id: number; name: string }[]> { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .execute(); + } + + /** CL19 (`labelIdsForFile`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_labels WHERE collection_id=?`. Dup text also `createLabel` (Task 2's own call site, CL73's neighbor). */ + async maxSortOrder(collectionId: number): Promise { + const row = await this.db_() + .selectFrom('collection_labels') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** CL20 (`insertImportedLabel`) — `INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?,?,?,?)`. Returns the new row's id. Dup text also `createLabel` (Task 2's own call site). */ + async insertLabel(row: { collection_id: number; name: string; color: string; sort_order: number }): Promise { + const result = await this.kysely().insertInto('collection_labels').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // ------------------------------------------------------------------------- + // Plan 3h Task 2 (part B) — CL71-77, additive. + // ------------------------------------------------------------------------- + + /** + * CL71 (`collectionIdOfLabel`) — `SELECT collection_id FROM + * collection_labels WHERE id=?`. Kysely, not `findOne({fields: + * ['collection_id']})` — `collection_id` is a `persist(false)` relation + * mirror, and the program-wide "a bare `persist(false)` mirror column + * selected via `fields:` is silently dropped" trap (3d/3e ledgers) + * applies here exactly as it does to a bare `.select([...])`. + */ + async collectionIdOf(labelId: number): Promise { + const row = await this.db_().selectFrom('collection_labels').select('collection_id').where('id', '=', labelId).executeTakeFirst(); + return row?.collection_id; + } + + /** CL72 (`getLabelById`) — `SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id=?`. Kysely, same `collection_id` mirror-column reason as {@link collectionIdOf}. */ + async findById(labelId: number): Promise { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'collection_id', 'name', 'color', 'sort_order']) + .where('id', '=', labelId) + .executeTakeFirst(); + } + + /** CL73 (`createLabel`) — `SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id=?`. */ + async countByCollection(collectionId: number): Promise { + return await this.count({ collection: collectionId }); + } + + /** + * CL74 (`createLabel`) — `SELECT 1 FROM collection_labels WHERE + * collection_id=? AND lower(name)=lower(?)`. Both sides folded by SQLite + * (`lower`/`lowerParam`'s pair, program rule 18) — the legacy statement + * lowers the bound value in SQL too, never in JS. + */ + async nameExists(collectionId: number, name: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { collection: collectionId, [lower(platform, 'name')]: lowerParam(platform, name) }, + { fields: ['id'] }, + ); + return !!row; + } + + /** CL75 (`updateLabel`) — same as {@link nameExists}, plus `AND id != ?` (excludes the label being renamed). */ + async nameExistsExcluding(collectionId: number, name: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { collection: collectionId, [lower(platform, 'name')]: lowerParam(platform, name), id: { $ne: excludeId } }, + { fields: ['id'] }, + ); + return !!row; + } + + /** CL76 (`updateLabel`) — the allow-listed dynamic `UPDATE collection_labels SET ... WHERE id=?`. No `updated_at` stamp — the table has no such column, and the legacy statement never wrote one. */ + async updateFields(labelId: number, write: Partial<{ name: string; color: string; sort_order: number }>): Promise { + if (Object.keys(write).length === 0) return; + await this.nativeUpdate({ id: labelId }, write); + } + + /** CL77 (`deleteLabel`) — `DELETE FROM collection_labels WHERE id=?` (CASCADE clears place assignments). */ + async deleteById(labelId: number): Promise { + await this.nativeDelete({ id: labelId }); + } +} diff --git a/server/src/db/repositories/CollectionMembers.repository.ts b/server/src/db/repositories/CollectionMembers.repository.ts new file mode 100644 index 000000000..0bf6ebb1b --- /dev/null +++ b/server/src/db/repositories/CollectionMembers.repository.ts @@ -0,0 +1,197 @@ +import type { CollectionMembers } from '../entities/CollectionMembers.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `buildMembers`'s owner row — `collections` joined to `users` (CL12). */ +export interface CollectionOwnerMemberRow { + user_id: number; + username: string; + email: string; + avatar: string | null; +} + +/** `buildMembers`'s accepted/pending member rows — `collection_members` joined to `users` (CL13). */ +export interface CollectionMemberRow extends CollectionOwnerMemberRow { + status: string; + role: string; +} + +interface CollectionOwnerKyselyDB { + collections: { id: number; owner_id: number }; + users: { id: number; username: string; email: string; avatar: string | null }; +} + +interface CollectionMembersKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string; role: string }; + users: { id: number; username: string; email: string; avatar: string | null }; +} + +interface CollectionMemberUserIdsKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string }; +} + +/** `listCollections`'s incoming-invites read — a genuine statement missing + * its own CL number in the plan's inventory gather (jumped CL15 → CL16 + * without capturing it): `SELECT cm.collection_id, c.name, u.id AS from_id, + * u.username AS from_username FROM collection_members cm JOIN collections c + * ON c.id=cm.collection_id JOIN users u ON u.id=c.owner_id WHERE + * cm.user_id=? AND cm.status='pending'`. */ +export interface CollectionIncomingInviteRow { + collection_id: number; + name: string; + from_id: number; + from_username: string; +} + +interface CollectionIncomingInvitesKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string }; + collections: { id: number; name: string; owner_id: number }; + users: { id: number; username: string }; +} + +/** + * `collection_members` — Plan 3h Task 1's first cut (`roleOf`'s own member + * lookup lives in `_shared/collection-role.ts` instead, R6). Task 2 extends + * this repository with `sendInvite`/`acceptInvite`/etc.'s invite-lifecycle + * methods (CL79-88) and the membership-lookup surface (CL69, `findMembership`). + * Owns `buildMembers`'s two joined reads (CL12/CL13) and `deleteCollection`'s + * snapshot-before-cascade reads (CL33/CL34). + */ +export class CollectionMembersRepository extends TrekRepository { + /** CL12 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar FROM collections col JOIN users u ON u.id=col.owner_id WHERE col.id=?`. */ + async ownerRow(collectionId: number): Promise { + return await this.kysely() + .selectFrom('collections as col') + .innerJoin('users as u', 'u.id', 'col.owner_id') + .select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar']) + .where('col.id', '=', collectionId) + .executeTakeFirst(); + } + + /** CL13 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role FROM collection_members cm JOIN users u ON u.id=cm.user_id WHERE cm.collection_id=? ORDER BY u.username`. */ + async memberRows(collectionId: number): Promise { + return await this.kysely() + .selectFrom('collection_members as cm') + .innerJoin('users as u', 'u.id', 'cm.user_id') + .select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar', 'cm.status', 'cm.role']) + .where('cm.collection_id', '=', collectionId) + .orderBy('u.username') + .execute(); + } + + /** CL33 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='accepted'`. Dup text also `collectionMemberIds`/`notifyCollectionUsers` (Task 2's own call sites). */ + async acceptedUserIds(collectionId: number): Promise { + const rows = await this.kysely() + .selectFrom('collection_members') + .select('user_id') + .where('collection_id', '=', collectionId) + .where('status', '=', 'accepted') + .execute(); + return rows.map((r) => r.user_id); + } + + /** CL34 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='pending'`. */ + async pendingUserIds(collectionId: number): Promise { + const rows = await this.kysely() + .selectFrom('collection_members') + .select('user_id') + .where('collection_id', '=', collectionId) + .where('status', '=', 'pending') + .execute(); + return rows.map((r) => r.user_id); + } + + /** `listCollections`'s incoming-invites read (see {@link CollectionIncomingInviteRow}'s docstring). */ + async pendingInvitesForUser(userId: number): Promise { + return await this.kysely() + .selectFrom('collection_members as cm') + .innerJoin('collections as c', 'c.id', 'cm.collection_id') + .innerJoin('users as u', 'u.id', 'c.owner_id') + .select(['cm.collection_id', 'c.name', 'u.id as from_id', 'u.username as from_username']) + .where('cm.user_id', '=', userId) + .where('cm.status', '=', 'pending') + .execute(); + } + + // ------------------------------------------------------------------------- + // Plan 3h Task 2 (part B) — CL79-88, additive: the invite lifecycle + // (sendInvite/acceptInvite/declineInvite/cancelInvite/leaveCollection/ + // removeMember/setMemberRole/availableUsers/findMembershipForUser). + // ------------------------------------------------------------------------- + + /** CL80 (`sendInvite`) — `SELECT id, status FROM collection_members WHERE collection_id=? AND user_id=?` (dup/pending guard). */ + async findByCollectionAndUser(collectionId: number, userId: number): Promise<{ id: number; status: string } | undefined> { + const row = await this.findOne({ collection: collectionId, user: userId }, { fields: ['id', 'status'] }); + return row ?? undefined; + } + + /** CL81 (`sendInvite`) — `INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)`. */ + async insertInvite(collectionId: number, userId: number, role: string): Promise { + await this.insert({ collection: collectionId, user: userId, status: 'pending', role }); + } + + /** CL82 (`acceptInvite`) — `SELECT id FROM collection_members WHERE collection_id=? AND user_id=? AND status='pending'`. */ + async findPendingInvite(collectionId: number, userId: number): Promise { + const row = await this.findOne({ collection: collectionId, user: userId, status: 'pending' }, { fields: ['id'] }); + return row?.id; + } + + /** CL83 (`acceptInvite`) — `UPDATE collection_members SET status='accepted' WHERE id=?`. */ + async accept(memberId: number): Promise { + await this.nativeUpdate({ id: memberId }, { status: 'accepted' }); + } + + /** CL84 (`declineInvite`, dup text also `cancelInvite`) — `DELETE FROM collection_members WHERE collection_id=? AND user_id=? AND status='pending'`. */ + async deletePending(collectionId: number, userId: number): Promise { + await this.nativeDelete({ collection: collectionId, user: userId, status: 'pending' }); + } + + /** CL85 (`leaveCollection`, dup text also `removeMember`) — `DELETE FROM collection_members WHERE collection_id=? AND user_id=? AND status='accepted'`. Returns the affected row count (`removeMember`'s own 404-on-zero check). */ + async deleteAccepted(collectionId: number, userId: number): Promise { + return await this.nativeDelete({ collection: collectionId, user: userId, status: 'accepted' }); + } + + /** CL86 (`setMemberRole`) — `UPDATE collection_members SET role=? WHERE collection_id=? AND user_id=? AND status='accepted'`. Returns the affected row count (0 ⇒ 404). */ + async setRole(collectionId: number, userId: number, role: string): Promise { + return await this.nativeUpdate({ collection: collectionId, user: userId, status: 'accepted' }, { role }); + } + + /** + * CL87 (`availableUsers`) — `SELECT u.id, u.username FROM users u WHERE + * u.id != ? AND u.id NOT IN (SELECT user_id FROM collection_members WHERE + * collection_id = ?) AND u.is_guest = 0 ORDER BY u.username`. Kysely — a + * `NOT IN` subquery against a different repository's own table (`users`), + * the same cross-entity shape `pendingInvitesForUser` above already uses. + */ + async availableUsers(ownerId: number, collectionId: number): Promise<{ id: number; username: string }[]> { + return await this.kysely() + .selectFrom('users as u') + .select(['u.id', 'u.username']) + .where('u.id', '!=', ownerId) + .where('u.is_guest', '=', 0) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('collection_members as cm') + .select('cm.user_id') + .whereRef('cm.user_id', '=', 'u.id') + .where('cm.collection_id', '=', collectionId), + ), + ), + ) + .orderBy('u.username') + .execute(); + } + + /** CL88 (`findMembershipForUser`) — `SELECT status FROM collection_members WHERE collection_id=? AND user_id=?` (no status filter — any row, pending or accepted). */ + async statusFor(collectionId: number, userId: number): Promise { + const row = await this.findOne({ collection: collectionId, user: userId }, { fields: ['status'] }); + return row?.status; + } +} + +/** {@link CollectionMembersRepository.availableUsers}'s narrow `users`/`collection_members` shape. */ +interface CollectionAvailableUsersKyselyDB { + users: { id: number; username: string; is_guest: number }; + collection_members: { collection_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/CollectionPlaceRatings.repository.ts b/server/src/db/repositories/CollectionPlaceRatings.repository.ts new file mode 100644 index 000000000..f30808f1c --- /dev/null +++ b/server/src/db/repositories/CollectionPlaceRatings.repository.ts @@ -0,0 +1,72 @@ +import type { CollectionPlaceRatings } from '../entities/CollectionPlaceRatings.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `collection_place_ratings` (Plan 3h Task 2, part B — #1435's per-voter + * star ratings on a saved place). A real entity/composite-unique table + * (`uniques: [{ properties: ['collectionPlace', 'user'] }]`, Task 0's + * entity/repository population table), unlike the two pivot tables this + * plan's own `collection_place_tags`/`collection_place_labels` conversions + * route through `CollectionPlacesRepository`'s Kysely bridge — writes here + * go through the MikroORM entity API using the entity's own relation + * property names (`collectionPlace`/`user`), `PlaceRatingsRepository + * .upsertRating`'s exact precedent for the sibling `place_ratings` table. + */ +export class CollectionPlaceRatingsRepository extends TrekRepository { + /** + * CL52 (`setRating`, `rating === null` branch) — `DELETE FROM + * collection_place_ratings WHERE collection_place_id=? AND user_id=?`. + */ + async deleteRating(collectionPlaceId: number, userId: number): Promise { + await this.nativeDelete({ collectionPlace: collectionPlaceId, user: userId }); + } + + /** + * CL53 (`setRating`, `rating !== null` branch) — `INSERT INTO + * collection_place_ratings (collection_place_id, user_id, rating) VALUES + * (?, ?, ?) ON CONFLICT(collection_place_id, user_id) DO UPDATE SET + * rating = excluded.rating`. A STRAIGHTFORWARD blind-overwrite composite + * upsert (the brief's own framing, confirmed against + * `PlaceRatingsRepository.upsertRating`, the closer precedent than R4's + * additive-counter shape): `onConflictFields: ['collectionPlace', 'user']` + * on the entity's own declared composite unique (property names, not + * column names — `collection_place_id`/`user_id` are `persist(false)` + * mirrors of those two relations), `onConflictMergeFields: ['rating']` + * so a future column never silently joins the merge set (same reasoning + * `PlaceRatingsRepository.upsertRating`'s own docstring gives). + */ + async upsertRating(collectionPlaceId: number, userId: number, rating: number): Promise { + await this.upsert( + { collectionPlace: collectionPlaceId, user: userId, rating }, + { onConflictFields: ['collectionPlace', 'user'], onConflictAction: 'merge', onConflictMergeFields: ['rating'] }, + ); + } + + /** + * CL41 (`copyTripRatings`, PREPARED/looped) — `INSERT OR IGNORE INTO + * collection_place_ratings (collection_place_id, user_id, rating) VALUES + * (?, ?, ?)`. Distinct from {@link upsertRating}'s `DO UPDATE` shape — a + * vote already carried over from a prior copy is left alone, not + * overwritten, `onConflictAction: 'ignore'` on the same composite unique. + */ + async insertIgnoreRating(collectionPlaceId: number, userId: number, rating: number): Promise { + await this.upsert( + { collectionPlace: collectionPlaceId, user: userId, rating }, + { onConflictFields: ['collectionPlace', 'user'], onConflictAction: 'ignore' }, + ); + } + + /** + * CL68 (`copyToTrip`, loop, TX) — `SELECT user_id, rating FROM + * collection_place_ratings WHERE collection_place_id=?`. `user_id` is a + * `persist(false)` relation mirror — Kysely, the program-wide "bare + * mirror column silently dropped" trap (3d/3e ledgers). + */ + async listForPlace(collectionPlaceId: number): Promise<{ user_id: number; rating: number }[]> { + return await this.kysely<{ collection_place_ratings: { collection_place_id: number; user_id: number; rating: number } }>() + .selectFrom('collection_place_ratings') + .select(['user_id', 'rating']) + .where('collection_place_id', '=', collectionPlaceId) + .execute(); + } +} diff --git a/server/src/db/repositories/CollectionPlaces.repository.ts b/server/src/db/repositories/CollectionPlaces.repository.ts new file mode 100644 index 000000000..23d0b762b --- /dev/null +++ b/server/src/db/repositories/CollectionPlaces.repository.ts @@ -0,0 +1,486 @@ +import type { CollectionPlaces } from '../entities/CollectionPlaces.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import type { CollectionPlaceRow } from './Collections.repository'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `collection_places` (Plan 3h Task 2, part B — saved places CRUD, dedup, + * copy-to-trip, library-wide membership lookup, label assignment). Task 1's + * own bridge methods for this table (CL10/11/16/17/22/23/25/26/27) stay on + * `CollectionsRepository` as its report flags — this repository is additive + * ONLY, every method here a NEW statement in Task 2's own CL37-69 range, + * never a re-derivation of what Task 1 already built. + * + * **Read/write split**: every READ here goes through `this.kysely()` rather + * than the MikroORM entity API, even for statements the entity COULD in + * principle express, because every read in this file's own range projects + * at least one `persist(false)` relation-mirror column (`collection_id`/ + * `category_id`) or needs `abs()`/dynamic OR conditions Kysely expresses + * more directly — the TRAP list's "a `persist(false)` mirror column + * selected bare is silently dropped, with or without an active join" + * finding (3d/3e ledgers) applies to a `qb().select([...])`/`fields: [...]` + * read exactly as much as it does to `find`, so Kysely sidesteps the whole + * class of bug rather than being audited statement-by-statement for it. + * WRITES go through the MikroORM entity API (`insert`/`nativeUpdate`/ + * `nativeDelete`) wherever the target table has an entity, using the + * entity's own relation property names (`collection`/`owner`/`category`) + * for FK columns — {@link PlaceRatingsRepository.upsertRating}'s + * `{ place: place_id, user: user_id }` precedent — since a write is never + * subject to the read-side narrowing trap. The two pivot tables this file + * also owns (`collection_place_tags`/`collection_place_labels`) have NO + * entity of their own (`Task 0's` entity/repository population table) and + * so go through Kysely unconditionally, both reads and writes. + */ +export class CollectionPlacesRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + // --------------------------------------------------------------------- + // CL37/CL38 — attachTags (private helper, savePlace/updatePlace) + // --------------------------------------------------------------------- + + /** + * CL38 (`attachTags`, PREPARED/looped in the legacy code) — `INSERT OR + * IGNORE INTO collection_place_tags (collection_place_id, tag_id) VALUES + * (?, ?)`, one call per eligible tag id. Batched into ONE Kysely insert + * with `onConflict(...).doNothing()` on the pivot's real composite + * `PRIMARY KEY (collection_place_id, tag_id)` — the exact shape + * `TagsRepository.insertIgnore` already established for `place_tags` + * (the sibling pivot in the 3c-owned `places` cluster). Empty `tagIds` + * short-circuits before any query. + */ + async attachTags(collectionPlaceId: number, tagIds: number[]): Promise { + if (tagIds.length === 0) return; + await this.db_() + .insertInto('collection_place_tags') + .values(tagIds.map((tag_id) => ({ collection_place_id: collectionPlaceId, tag_id }))) + .onConflict((oc) => oc.columns(['collection_place_id', 'tag_id']).doNothing()) + .execute(); + } + + // --------------------------------------------------------------------- + // CL42/CL46 — savePlace / saveFromTripPlaces inserts + // --------------------------------------------------------------------- + + /** + * CL42 (`savePlace`, TX) AND CL46 (`saveFromTripPlaces`, PREPARED/looped, + * TX) — collapsed onto ONE method, not two: both legacy statements share + * the IDENTICAL 22-column `INSERT INTO collection_places (collection_id, + * owner_id, saved_by, name, description, lat, lng, address, category_id, + * price, currency, notes, image_url, google_place_id, google_ftid, + * osm_id, website, phone, status, source_trip_id, source_place_id, + * links)` list in the SAME order — CL46's only difference is that + * `status`/`links` are bound SQL literals (`'idea'`/`NULL`) instead of + * placeholders, which is a caller-side VALUE difference, not a distinct + * statement shape (unlike CL23's file-import insert, which genuinely + * carries a different column set — `sort_order`, no `source_trip_id`/ + * `source_place_id` — and stays its own method on `CollectionsRepository`, + * per the plan's own "do not collapse" instruction for that pair). + * `saveFromTripPlaces`'s own call site passes `status: 'idea', links: + * null` explicitly, reproducing the literal exactly. `em.insert()` + * returns the generated id; `sort_order` is never in the column list + * either statement writes, so the table's own `DEFAULT 0` fills it, + * matching both legacy statements. + */ + async insertSavedPlace(row: { + collection_id: number; + owner_id: number; + saved_by: 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; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + source_trip_id: number | null; + source_place_id: number | null; + links: string | null; + }): Promise { + return await this.insert({ + collection: row.collection_id, + owner: row.owner_id, + savedByRef: row.saved_by, + name: row.name, + description: row.description, + lat: row.lat, + lng: row.lng, + address: row.address, + category: row.category_id, + price: row.price, + currency: row.currency, + notes: row.notes, + image_url: row.image_url, + google_place_id: row.google_place_id, + google_ftid: row.google_ftid, + osm_id: row.osm_id, + website: row.website, + phone: row.phone, + status: row.status, + source_trip_id: row.source_trip_id, + source_place_id: row.source_place_id, + links: row.links, + }); + } + + // --------------------------------------------------------------------- + // CL47/CL48 — updatePlace + // --------------------------------------------------------------------- + + /** CL47 (`updatePlace`'s pre-image read, dup text also `deletePlace`/`deletePlacesMany`/`setPlaceImage`) — `SELECT image_url FROM collection_places WHERE id=?`. Plain (non-FK) column — safe through the entity API, `fields: ['image_url']`. */ + async imageUrl(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['image_url'] }); + return row?.image_url; + } + + /** + * CL48 (`updatePlace`, TX) — the allow-listed dynamic `UPDATE + * collection_places SET ... WHERE id=?`, including the cross-list move + * branch (`collection_id`/`owner_id`). `write` carries exactly the + * columns the caller built (the service resolves `body.field !== + * undefined` itself, `CollectionsRepository.updateFields`'s same shape) + * — an empty `write` short-circuits, matching the legacy `if + * (updates.length > 0)` guard. FK columns are translated to their + * entity relation property names for the write + * (`collection_id`→`collection`, `owner_id`→`owner`, `category_id`→ + * `category`) — `PlaceRatingsRepository.upsertRating`'s precedent for + * writing a `persist(false)` mirror through its owning relation. + */ + async updateFields( + id: number, + write: Partial<{ + name: string; + description: string | null; + notes: string | null; + lat: number | null; + lng: number | null; + address: string | null; + status: string; + category_id: number | null; + image_url: string | null; + links: string | null; + collection_id: number; + owner_id: number; + }>, + ): Promise { + if (Object.keys(write).length === 0) return; + const platform = this.getEntityManager().getPlatform(); + const { category_id, collection_id, owner_id, ...rest } = write; + await this.nativeUpdate( + { id }, + { + ...rest, + ...(category_id !== undefined ? { category: category_id } : {}), + ...(collection_id !== undefined ? { collection: collection_id } : {}), + ...(owner_id !== undefined ? { owner: owner_id } : {}), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** CL49 (`updatePlace`, TX) — `DELETE FROM collection_place_tags WHERE collection_place_id=?`. Pivot table, Kysely. */ + async deleteTags(collectionPlaceId: number): Promise { + await this.db_().deleteFrom('collection_place_tags').where('collection_place_id', '=', collectionPlaceId).execute(); + } + + /** CL50 (`updatePlace`'s move branch, TX, dup text also `setPlaceLabels`) — `DELETE FROM collection_place_labels WHERE collection_place_id=?`. Pivot table, Kysely. */ + async deleteLabelAssignments(collectionPlaceId: number): Promise { + await this.db_().deleteFrom('collection_place_labels').where('collection_place_id', '=', collectionPlaceId).execute(); + } + + // --------------------------------------------------------------------- + // CL51/CL52/CL53 — setStatus / setRating + // --------------------------------------------------------------------- + + /** CL51 (`setStatus`) — `UPDATE collection_places SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setStatus(id: number, status: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL54 — deletePlace / deletePlacesMany + // --------------------------------------------------------------------- + + /** CL54 (`deletePlace`, dup text also `deletePlacesMany`'s TX loop) — `DELETE FROM collection_places WHERE id=?` (CASCADE drops tags/labels/ratings). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------- + // CL56 — setStatusMany + // --------------------------------------------------------------------- + + /** + * CL56 (`setStatusMany`, TX) — `UPDATE collection_places SET status=?, + * updated_at=CURRENT_TIMESTAMP WHERE id=? AND status IS NOT ?`, distinct + * from CL51 by the extra `status IS NOT ?` guard. Returns the affected + * row count (0 or 1) — the caller sums it across the batch, matching the + * legacy `res.changes` accumulation. + */ + async setStatusIfChanged(id: number, status: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.nativeUpdate({ id, status: { $ne: status } }, { status, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL58 — matchingCollectionPlaces (setStatusFromTrip's own private helper) + // --------------------------------------------------------------------- + + /** + * CL58 (`matchingCollectionPlaces`) — `SELECT cp.id FROM collection_places + * cp WHERE cp.collection_id IN (...) AND (${dynamic OR conditions})`. + * RULE 23: the dynamic OR is a typed array of Kysely expressions built + * conditionally (never a string-concatenated WHERE) — the base + * `source_trip_id`/`source_place_id` pair is always present (an AND'd + * sub-clause inside the outer OR, matching the legacy's own parenthesised + * first condition), and each provider-id/coordinate-tolerance clause is + * pushed onto the array only when the corresponding input field is + * present, exactly reproducing the legacy `conditions.push(...)` guards. + * `tolerance` is caller-supplied (`CollectionsRepository + * .findDuplicateByCoords`'s own precedent), not imported from + * `nest/places/places.helpers` here — a `db/` repository does not reach + * into `nest/`. + */ + async matchingByTripSource( + collectionIds: number[], + tripId: number, + place: { id: number; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }, + tolerance: number, + ): Promise<{ id: number }[]> { + if (collectionIds.length === 0) return []; + return await this.db_() + .selectFrom('collection_places as cp') + .select('cp.id') + .where('cp.collection_id', 'in', collectionIds) + .where((eb) => { + const clauses = [eb.and([eb('cp.source_trip_id', '=', tripId), eb('cp.source_place_id', '=', place.id)])]; + if (place.google_place_id) clauses.push(eb('cp.google_place_id', '=', place.google_place_id)); + if (place.google_ftid) clauses.push(eb('cp.google_ftid', '=', place.google_ftid)); + if (place.osm_id) clauses.push(eb('cp.osm_id', '=', place.osm_id)); + if (place.lat != null && place.lng != null) { + clauses.push( + eb.and([ + eb('cp.lat', 'is not', null), + eb('cp.lng', 'is not', null), + eb(eb.fn('abs', [eb('cp.lat', '-', place.lat)]), '<=', tolerance), + eb(eb.fn('abs', [eb('cp.lng', '-', place.lng)]), '<=', tolerance), + ]), + ); + } + return eb.or(clauses); + }) + .execute(); + } + + // --------------------------------------------------------------------- + // CL59 — setPlaceImage + // --------------------------------------------------------------------- + + /** CL59 (`setPlaceImage`) — `UPDATE collection_places SET image_url=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setImageUrl(id: number, imageUrl: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { image_url: imageUrl, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL62 — copyToTrip's per-source-id read + // --------------------------------------------------------------------- + + /** + * CL62 (`copyToTrip`, per source id) — `SELECT id, collection_id, name, + * description, lat, lng, address, category_id, price, currency, notes, + * image_url, google_place_id, google_ftid, osm_id, website, phone FROM + * collection_places WHERE id=?`. `collection_id`/`category_id` in the + * projection — Kysely, the class docstring's read-side rule. + */ + async findForCopy(id: number): Promise { + return await this.db_() + .selectFrom('collection_places') + .select([ + 'id', 'collection_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', + 'notes', 'image_url', 'google_place_id', 'google_ftid', 'osm_id', 'website', 'phone', + ]) + .where('id', '=', id) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------- + // CL67 — copyToTrip's per-source tag read (loop, TX) + // --------------------------------------------------------------------- + + /** CL67 (`copyToTrip`, loop, TX) — `SELECT tag_id FROM collection_place_tags WHERE collection_place_id=?`. Pivot table, Kysely. */ + async tagIdsFor(collectionPlaceId: number): Promise { + const rows = await this.db_().selectFrom('collection_place_tags').select('tag_id').where('collection_place_id', '=', collectionPlaceId).execute(); + return rows.map((r) => r.tag_id); + } + + // --------------------------------------------------------------------- + // CL69 — findMembership (library-wide membership lookup) + // --------------------------------------------------------------------- + + /** + * CL69 (`findMembership`) — `SELECT cp.id AS place_id, cp.collection_id, + * c.name, cp.status FROM collection_places cp JOIN collections c ON + * c.id=cp.collection_id WHERE cp.collection_id IN (...) AND (${dynamic + * conditions})`. RULE 23, same shape as {@link matchingByTripSource} + * above, but genuinely a DIFFERENT statement (no `source_trip_id`/ + * `source_place_id` base clause, no `osm_id` condition — the legacy + * `findMembership` never checks it — and an extra `collections` join for + * the list name), so this is its own method rather than a parameterised + * reuse of `matchingByTripSource` (one repository method per legacy + * statement). Returns `[]` when `query` carries no usable condition, + * matching the service's own `if (conditions.length === 0) return + * { saved: false, lists: [] };` guard — defensive here too, though the + * caller already checks first. + */ + async searchMembership( + collectionIds: number[], + query: { google_place_id?: string; google_ftid?: string; lat?: number; lng?: number }, + tolerance: number, + ): Promise<{ place_id: number; collection_id: number; name: string; status: string }[]> { + if (collectionIds.length === 0) return []; + const hasCoords = query.lat != null && query.lng != null; + if (!query.google_place_id && !query.google_ftid && !hasCoords) return []; + return await this.kysely() + .selectFrom('collection_places as cp') + .innerJoin('collections as c', 'c.id', 'cp.collection_id') + .select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'cp.status']) + .where('cp.collection_id', 'in', collectionIds) + .where((eb) => { + const clauses = []; + if (query.google_place_id) clauses.push(eb('cp.google_place_id', '=', query.google_place_id)); + if (query.google_ftid) clauses.push(eb('cp.google_ftid', '=', query.google_ftid)); + if (hasCoords) { + clauses.push( + eb.and([ + eb('cp.lat', 'is not', null), + eb('cp.lng', 'is not', null), + eb(eb.fn('abs', [eb('cp.lat', '-', query.lat as number)]), '<=', tolerance), + eb(eb.fn('abs', [eb('cp.lng', '-', query.lng as number)]), '<=', tolerance), + ]), + ); + } + return eb.or(clauses); + }) + .execute(); + } + + // --------------------------------------------------------------------- + // CL78 — assignLabels' remove branch (TX) + // --------------------------------------------------------------------- + + /** + * CL78 (`assignLabels`'s remove branch, PREPARED/looped, TX) — `DELETE + * FROM collection_place_labels WHERE collection_place_id=? AND + * label_id=?`, per pair in the service's own nested loop (mirrors + * `CollectionsRepository.assignPlaceLabel`'s CL24 per-pair shape, the + * insert branch's own repository call). Pivot table, Kysely. Returns the + * deleted row count (0 or 1) for the caller's `changed +=` accumulation. + */ + async unassignLabel(collectionPlaceId: number, labelId: number): Promise { + const result = await this.db_() + .deleteFrom('collection_place_labels') + .where('collection_place_id', '=', collectionPlaceId) + .where('label_id', '=', labelId) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } + + /** + * `assignLabels`'s own insert branch (its own text is `CL24`'s dup, `:1488` + * per Task 1's report) — same `INSERT OR IGNORE INTO collection_place_labels + * (collection_place_id, label_id) VALUES (?, ?)` shape as + * `CollectionsRepository.assignPlaceLabel`, but with a COUNT-returning + * contract that method doesn't have (it returns `void` — `setPlaceLabels`'s + * own call site, the sole other CL24 consumer, never needed one). `assignLabels` + * sums the affected-row count across every pair (matching the legacy's own + * `res.changes` accumulation — an already-assigned label contributes 0, a + * newly-assigned one contributes 1), which `void` cannot answer. A second + * method rather than widening Task 1's committed signature, per the + * brief's "Task 1's methods untouched" instruction. + */ + async assignLabel(collectionPlaceId: number, labelId: number): Promise { + const result = await this.db_() + .insertInto('collection_place_labels') + .values({ collection_place_id: collectionPlaceId, label_id: labelId }) + .onConflict((oc) => oc.columns(['collection_place_id', 'label_id']).doNothing()) + .executeTakeFirst(); + return Number(result.numInsertedOrUpdatedRows ?? 0); + } + + // --------------------------------------------------------------------- + // Plan 3h Task 6 (survivors — `places.service.ts` SV-PI2, `place-photo- + // cache.service.ts` SV-PP6, `place-image.ts` SV-PLACEIMG-2) — additive. + // --------------------------------------------------------------------- + + /** + * SV-PI2 (`places.service.ts::reclaimPlaceImage`) / SV-PLACEIMG-2 + * (`CollectionsService`'s own private `reclaimPlaceImage`, replacing + * `place-image.ts`'s free function) — `SELECT 1 FROM collection_places + * WHERE image_url = ? LIMIT 1`. Trip-agnostic, same shape as + * `PlacesRepository.existsByImageUrl` for its own table. + */ + async existsByImageUrl(imageUrl: string): Promise { + const row = await this.db_().selectFrom('collection_places').select('id').where('image_url', '=', imageUrl).limit(1).executeTakeFirst(); + return !!row; + } + + /** + * SV-PP6 (`place-photo-cache.service.ts::isReferenced`) — `SELECT 1 FROM + * collection_places WHERE google_place_id = ? OR image_url = ? LIMIT 1`, + * the same shape `PlacesRepository.existsByGoogleIdOrImageUrl` uses for + * its own table. + */ + async existsByGoogleIdOrImageUrl(googlePlaceId: string, imageUrl: string): Promise { + const row = await this.db_() + .selectFrom('collection_places') + .select('id') + .where((eb) => eb.or([eb('google_place_id', '=', googlePlaceId), eb('image_url', '=', imageUrl)])) + .limit(1) + .executeTakeFirst(); + return !!row; + } +} + +/** {@link CollectionPlacesRepository.findForCopy}'s narrow projection (CL62). */ +export interface CollectionPlaceCopyRow { + id: number; + collection_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; + osm_id: string | null; + website: string | null; + phone: string | null; +} + +interface CollectionPlacesKyselyDB { + collection_places: Omit; + collection_place_tags: { collection_place_id: number; tag_id: number }; + collection_place_labels: { collection_place_id: number; label_id: number }; +} + +interface CollectionPlacesMembershipKyselyDB { + collection_places: Pick; + collections: { id: number; name: string }; +} diff --git a/server/src/db/repositories/Collections.repository.ts b/server/src/db/repositories/Collections.repository.ts new file mode 100644 index 000000000..97f956986 --- /dev/null +++ b/server/src/db/repositories/Collections.repository.ts @@ -0,0 +1,504 @@ +import type { Collections } from '../entities/Collections.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `collections` — every scalar column, `getCollectionRow`'s (CL14) `SELECT *` shape. */ +export interface CollectionRow { + id: number; + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number | null; + created_at: string | null; + updated_at: string | null; +} + +/** + * A saved place row, `cp.*` joined to its category name/color/icon — CL10 + * (`getPlaceById`, filtered by `cp.id`) and CL16/CL17 (`getCollection`/ + * `exportCollection`'s dup-text pair, filtered by `cp.collection_id`) both + * select this exact shape. `links` stays the raw TEXT column (`string | + * null`) here — never `CollectionLink[]` — the service's own `parseLinks` + * does that parsing on the way out, the same reason `CollectionRow.links` + * below is also raw TEXT: a hand-written row interface documents the DB + * shape, not the wire shape (`_shared/rows.ts`'s docstring). + */ +export interface CollectionPlaceRow { + id: number; + collection_id: number; + owner_id: number; + saved_by: number | null; + 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; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + source_trip_id: number | null; + source_place_id: number | null; + links: string | null; + sort_order: number | null; + created_at: string | null; + updated_at: string | null; + category_name: string | null; + category_color: string | null; + category_icon: string | null; +} + +interface CollectionsKyselyDB { + collections: CollectionRow; +} + +/** + * CL29's own `INSERT INTO collections (owner_id, name, description, color, + * icon, cover_image, links, sort_order)` column list, exactly — no `id`/ + * `created_at`/`updated_at` (never bound; the schema's own + * `DEFAULT CURRENT_TIMESTAMP`/autoincrement fill them), the + * `BucketListRepository`/`BucketListInsertKyselyDB` precedent for a + * narrower insert-only Kysely interface instead of `Generated<>` markers. + */ +interface CollectionsInsertKyselyDB { + collections: { + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }; +} + +interface CollectionIdsKyselyDB { + collections: { id: number; owner_id: number }; + collection_members: { collection_id: number; user_id: number; status: string }; +} + +interface CollectionPlacesReadKyselyDB { + collection_places: Omit; + categories: { id: number; name: string; color: string | null; icon: string | null }; +} + +/** + * CL23's own `INSERT INTO collection_places (21 cols)` list, exactly — no + * `id`/`created_at`/`updated_at`, the same narrower-insert-interface shape + * as {@link CollectionsInsertKyselyDB} above. + */ +interface CollectionPlacesWriteKyselyDB { + collection_places: { + collection_id: number; + owner_id: number; + saved_by: number | null; + 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; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + links: string | null; + sort_order: number | null; + }; +} + +interface CollectionPlaceLabelsKyselyDB { + collection_place_labels: { collection_place_id: number; label_id: number }; +} + +/** + * `collections` — the root of the collections cluster (Plan 3h Task 1, part + * A). Owns every CL1-CL36 statement not moved to `CollectionMembersRepository`/ + * `CollectionLabelsRepository`: the `collections` table's own CRUD/reorder, + * plus — as a pragmatic first cut, since `CollectionPlacesRepository`/ + * `CollectionPlaceTagsRepository`/`CollectionPlaceRatingsRepository` don't + * exist yet (Task 2 builds them) — every hydration/export/import/dedup + * statement Part A's own methods (`hydratePlaces`, `getPlaceById`, + * `getCollection`/`exportCollection`, `writeFilePlaces`, + * `findDuplicateCollectionPlace`) need against `collection_places`/ + * `collection_place_tags`/`collection_place_labels`/`collection_place_ratings`. + * Task 2's own report should note whether any of these bridge methods move + * onto its new, more specific repositories once they exist — see this + * task's own report for the explicit flag. + */ +export class CollectionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private insertDb() { + return this.kysely(); + } + + private idsDb() { + return this.kysely(); + } + + private placesReadDb() { + return this.kysely(); + } + + private placesWriteDb() { + return this.kysely(); + } + + private placeLabelsDb() { + return this.kysely(); + } + + /** + * CL1 (`accessibleCollectionIds`) — `SELECT id FROM collections WHERE + * owner_id=? UNION SELECT collection_id FROM collection_members WHERE + * user_id=? AND status='accepted'`. Two typed selects, merged and + * deduplicated in TS (`Set`) the same way SQL's `UNION` (not `UNION ALL`) + * would — the legacy statement is `UNION`, so a row present in both halves + * collapses to one id either way. + */ + async accessibleCollectionIds(userId: number): Promise { + const owned = await this.idsDb().selectFrom('collections').select('id').where('owner_id', '=', userId).execute(); + const member = await this.idsDb() + .selectFrom('collection_members') + .select('collection_id as id') + .where('user_id', '=', userId) + .where('status', '=', 'accepted') + .execute(); + return [...new Set([...owned.map((r) => r.id), ...member.map((r) => r.id)])]; + } + + /** CL5 (`ownerOf`) — `SELECT owner_id FROM collections WHERE id=?`. */ + async ownerId(id: number): Promise { + const row = await this.readDb().selectFrom('collections').select('owner_id').where('id', '=', id).executeTakeFirst(); + return row?.owner_id; + } + + /** CL14 (`getCollectionRow`) — `SELECT * FROM collections WHERE id=?`. */ + async findRow(id: number): Promise { + return await this.readDb().selectFrom('collections').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** CL15 (`getCollectionRow`) — `SELECT COUNT(*) AS n FROM collection_places WHERE collection_id=?`. */ + async placeCount(collectionId: number): Promise { + const row = await this.placesReadDb() + .selectFrom('collection_places') + .select((eb) => eb.fn.countAll().as('n')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.n); + } + + /** CL28 (`createCollection`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collections WHERE owner_id=?`. */ + async maxSortOrder(ownerId: number): Promise { + const row = await this.readDb() + .selectFrom('collections') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('owner_id', '=', ownerId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** + * CL29 (`createCollection`) — `INSERT INTO collections (owner_id, name, + * description, color, icon, cover_image, links, sort_order) VALUES (...)`. + * Returns the new row's id. + */ + async insertCollection(row: { + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }): Promise { + const result = await this.insertDb().insertInto('collections').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * CL30 (`updateCollection`) — the allow-listed dynamic `UPDATE collections + * SET ... WHERE id=?`. `write` carries exactly the columns the caller + * built (the service resolves `body.field !== undefined` itself, same + * shape as every other repository's `presenceSet`-fed write) — an empty + * `write` short-circuits before the `UPDATE`, matching the legacy + * `if (updates.length > 0)` guard. + */ + async updateFields( + id: number, + write: Partial<{ + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }>, + ): Promise { + if (Object.keys(write).length === 0) return; + await this.readDb().updateTable('collections').set({ ...write, updated_at: new Date().toISOString() }).where('id', '=', id).execute(); + } + + /** CL31 (`setCollectionCover`) — `SELECT cover_image FROM collections WHERE id=?`. */ + async coverImage(id: number): Promise { + const row = await this.readDb().selectFrom('collections').select('cover_image').where('id', '=', id).executeTakeFirst(); + return row?.cover_image; + } + + /** CL32 (`setCollectionCover`) — `UPDATE collections SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setCoverImage(id: number, coverUrl: string | null): Promise { + await this.readDb().updateTable('collections').set({ cover_image: coverUrl, updated_at: new Date().toISOString() }).where('id', '=', id).execute(); + } + + /** CL35 (`deleteCollection`) — `DELETE FROM collections WHERE id=?` (CASCADE drops members + places + tags). */ + async deleteById(id: number): Promise { + await this.readDb().deleteFrom('collections').where('id', '=', id).execute(); + } + + /** + * CL36 (`reorderCollections`, TX `:712`) — `UPDATE collections SET + * sort_order=? WHERE id=?`, looped per visible id. The legacy statement + * is a `this.db.prepare(...)` reused across the loop (a single compiled + * statement, `.run()` called once per iteration) — Kysely's query builder + * has no equivalent "compile once, bind many" primitive for an UPDATE + * with per-row values, so this issues one `updateTable` call per pair + * instead (informational for Task 7: the "prepare once, loop `.run()`" + * shape does NOT survive this conversion, per-call is what the repository + * layer supports here). + */ + async setSortOrders(pairs: { id: number; sortOrder: number }[]): Promise { + const db = this.readDb(); + for (const { id, sortOrder } of pairs) { + await db.updateTable('collections').set({ sort_order: sortOrder }).where('id', '=', id).execute(); + } + } + + // ------------------------------------------------------------------------- + // Hydration / export / import / dedup bridge methods — CL6, CL8-CL11, + // CL16/17 (collapsed), CL22-CL27. First-cut home: these touch + // `collection_places`/`collection_place_tags`/`collection_place_labels`/ + // `collection_place_ratings`, tables Task 2's own repositories will own + // going forward, but Part A's `hydratePlaces`/`getPlaceById`/ + // `getCollection`/`exportCollection`/`writeFilePlaces`/ + // `findDuplicateCollectionPlace` are its own methods and need somewhere to + // land now. + // ------------------------------------------------------------------------- + + /** CL10 (`getPlaceById`) — `SELECT cp.*, c.name/color/icon AS category_* FROM collection_places cp LEFT JOIN categories c ON cp.category_id=c.id WHERE cp.id=?`. */ + async findPlaceRowById(id: number): Promise { + return await this.placesReadDb() + .selectFrom('collection_places as cp') + .leftJoin('categories as c', 'c.id', 'cp.category_id') + .select([ + 'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng', + 'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id', + 'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id', + 'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('cp.id', '=', id) + .executeTakeFirst(); + } + + /** + * CL16/CL17 (`getCollection`/`exportCollection`'s dup-text pair, collapsed + * onto ONE repository method as the brief directs) — the same + * `cp.*`/category-alias select as {@link findPlaceRowById}, scoped by + * `cp.collection_id` and ordered by `cp.sort_order, cp.created_at`. + */ + async listPlaceRows(collectionId: number): Promise { + return await this.placesReadDb() + .selectFrom('collection_places as cp') + .leftJoin('categories as c', 'c.id', 'cp.category_id') + .select([ + 'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng', + 'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id', + 'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id', + 'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('cp.collection_id', '=', collectionId) + .orderBy('cp.sort_order') + .orderBy('cp.created_at') + .execute(); + } + + /** CL11 (`collectionIdOfPlace`) — `SELECT collection_id FROM collection_places WHERE id=?`. */ + async collectionIdOfPlace(placeId: number): Promise { + const row = await this.placesReadDb().selectFrom('collection_places').select('collection_id').where('id', '=', placeId).executeTakeFirst(); + return row?.collection_id; + } + + /** + * CL6 (`loadTagsByCollectionPlaceIds`) — `SELECT cpt.collection_place_id + * AS pid, t.id, t.name, t.color FROM collection_place_tags cpt JOIN tags t + * ON t.id=cpt.tag_id WHERE cpt.collection_place_id IN (...)`. Empty input + * short-circuits to `[]` (no `IN ()` sent) — the service's own batched + * hydration helper already guards this, but the repository guards too so + * it is safe to call directly. + */ + async loadTagsByPlaceIds(placeIds: number[]): Promise<{ pid: number; id: number; name: string; color: string }[]> { + if (placeIds.length === 0) return []; + return await this.kysely<{ collection_place_tags: { collection_place_id: number; tag_id: number }; tags: { id: number; name: string; color: string } }>() + .selectFrom('collection_place_tags as cpt') + .innerJoin('tags as t', 't.id', 'cpt.tag_id') + .select(['cpt.collection_place_id as pid', 't.id', 't.name', 't.color']) + .where('cpt.collection_place_id', 'in', placeIds) + .execute(); + } + + /** CL8 (`loadLabelIdsByPlaceIds`) — `SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (...)`. */ + async loadLabelIdsByPlaceIds(placeIds: number[]): Promise<{ pid: number; label_id: number }[]> { + if (placeIds.length === 0) return []; + const rows = await this.placeLabelsDb() + .selectFrom('collection_place_labels') + .select(['collection_place_id as pid', 'label_id']) + .where('collection_place_id', 'in', placeIds) + .execute(); + return rows; + } + + /** + * CL9 (`loadRatingsByCollectionPlaceIds`, #1435) — `SELECT + * cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, + * cpr.rating FROM collection_place_ratings cpr JOIN users u ON + * u.id=cpr.user_id WHERE cpr.collection_place_id IN (...) ORDER BY + * cpr.created_at`. + */ + async loadRatingsByPlaceIds( + placeIds: number[], + ): Promise<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }[]> { + if (placeIds.length === 0) return []; + return await this.kysely<{ + collection_place_ratings: { collection_place_id: number; user_id: number; rating: number; created_at: string | null }; + users: { id: number; username: string; avatar: string | null }; + }>() + .selectFrom('collection_place_ratings as cpr') + .innerJoin('users as u', 'u.id', 'cpr.user_id') + .select(['cpr.collection_place_id as pid', 'cpr.user_id', 'u.username', 'u.avatar', 'cpr.rating']) + .where('cpr.collection_place_id', 'in', placeIds) + .orderBy('cpr.created_at') + .execute(); + } + + /** CL22 (`writeFilePlaces`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_places WHERE collection_id=?`. */ + async maxPlaceSortOrder(collectionId: number): Promise { + const row = await this.placesReadDb() + .selectFrom('collection_places') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** + * CL23 (`writeFilePlaces`, PREPARED/looped in the legacy code) — `INSERT + * INTO collection_places (21 cols incl. sort_order, no source_trip_id/ + * source_place_id) VALUES (...)`. Distinct insert shape from CL42/CL46 + * (Task 2's) — never collapsed with either. Returns the new row's id. + */ + async insertFilePlace(row: { + collection_id: number; + owner_id: number; + saved_by: 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; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + links: string | null; + sort_order: number; + }): Promise { + const result = await this.placesWriteDb().insertInto('collection_places').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** CL24 (`writeFilePlaces`'s `assignLabel` prepared statement) — `INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?,?)`. */ + async assignPlaceLabel(collectionPlaceId: number, labelId: number): Promise { + await this.placeLabelsDb() + .insertInto('collection_place_labels') + .values({ collection_place_id: collectionPlaceId, label_id: labelId }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** CL25 (`findDuplicateCollectionPlace`, externalId branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND (google_place_id=? OR google_ftid=? OR osm_id=?) ORDER BY id ASC LIMIT 1`. */ + async findDuplicateByExternalId( + collectionId: number, + externalId: string, + ): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where((eb) => eb.or([eb('google_place_id', '=', externalId), eb('google_ftid', '=', externalId), eb('osm_id', '=', externalId)])) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } + + /** CL26 (`findDuplicateCollectionPlace`, name branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lower(trim(name))=? ORDER BY id ASC LIMIT 1`. `normalizedName` is already `lower(trim(...))`-shaped by the caller (`_shared/../dialect/sql-functions.ts`'s `lowerTrim`/`lowerTrimParam` precedent). */ + async findDuplicateByName(collectionId: number, normalizedName: string): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where((eb) => eb(eb.fn('lower', [eb.fn('trim', ['name'])]), '=', normalizedName)) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } + + /** CL27 (`findDuplicateCollectionPlace`, coord branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lat IS NOT NULL AND lng IS NOT NULL AND abs(lat-?)<=? AND abs(lng-?)<=? ORDER BY id ASC LIMIT 1`. */ + async findDuplicateByCoords( + collectionId: number, + lat: number, + lng: number, + tolerance: number, + ): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where('lat', 'is not', null) + .where('lng', 'is not', null) + .where((eb) => eb(eb.fn('abs', [eb('lat', '-', lat)]), '<=', tolerance)) + .where((eb) => eb(eb.fn('abs', [eb('lng', '-', lng)]), '<=', tolerance)) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } +} diff --git a/server/src/db/repositories/DawarichConnections.repository.ts b/server/src/db/repositories/DawarichConnections.repository.ts new file mode 100644 index 000000000..2dbb381c0 --- /dev/null +++ b/server/src/db/repositories/DawarichConnections.repository.ts @@ -0,0 +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'; + +/** + * `dawarich_connections` — every scalar column, for {@link AssertRowKeys}'s + * pin only. No method below returns this exact shape: DWC1's `findRow` + * covers 9 of the 11 (the legacy `readRow` statement never selected + * `created_at`/`updated_at`), and nothing else reads a full row. + */ +interface DawarichConnectionsAllColumns { + user_id: number; + url: string | null; + api_key: string | null; + allow_insecure_tls: number; + sync_enabled: number; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + capabilities: string | null; + created_at: string | null; + updated_at: string | null; +} +const _dawarichConnectionsAllColumns: AssertRowKeys = true; + +/** + * DWC1's own 9-column read (`readRow`'s legacy statement text). **R7**: + * `api_key` is the still-encrypted TEXT value, passed through untouched — + * `maybe_encrypt_api_key`/`decrypt_api_key` are called from `DawarichService` + * only, never from this repository. + */ +export interface DawarichConnectionRow { + user_id: number; + url: string | null; + api_key: string | null; + allow_insecure_tls: number; + sync_enabled: number; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + capabilities: string | null; +} + +/** + * `dawarich_connections` — one row per user, `user` (mirrored as `user_id`) + * the entity's own primary key (`[PrimaryKeyProp]?: 'user'`, same shape as + * `VacayUserSettingsRepository`'s own table). Every dawarich file depends on + * this repository's `getCredentials`/`listSyncableUserIds`/ + * `recordSyncResult` (via `DawarichService`) — convert this one first. + */ +export class DawarichConnectionsRepository extends TrekRepository { + /** + * DWC1 — `readRow`: `SELECT user_id, url, api_key, allow_insecure_tls, + * sync_enabled, last_sync_at, last_sync_state, last_sync_error, + * capabilities FROM dawarich_connections WHERE user_id = ?`. + * `mapResults: false` (matching `TripsRepository.findAccessible`'s own + * PK-is-FK read) so the raw driver row is returned as-is — `c.user` is the + * relation property; SQL still names the physical `user_id` column. + */ + async findRow(userId: number): Promise { + const row = await this.qb('c') + .select(['c.user', 'c.url', 'c.api_key', 'c.allow_insecure_tls', 'c.sync_enabled', 'c.last_sync_at', 'c.last_sync_state', 'c.last_sync_error', 'c.capabilities']) + .where({ user: userId }) + .execute('get', false); + return row ?? null; + } + + /** + * DWC2 — `listSyncableUserIds`: `SELECT user_id FROM dawarich_connections + * WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS + * NOT NULL`. **NON-HTTP ENTRYPOINT SOURCE** — the cron's user list. Three + * `.andWhere` calls, not one combined filter object: `url`'s two + * conditions (`IS NOT NULL` and `<> ''`) cannot both live under one + * `{ url: {...} }` key. + */ + async listSyncableUserIds(): Promise { + const rows = await this.qb('c') + .select(['c.user']) + .where({ sync_enabled: 1 }) + .andWhere({ url: { $ne: null } }) + .andWhere({ url: { $ne: '' } }) + .andWhere({ api_key: { $ne: null } }) + .execute<{ user_id: number }[]>('all', false); + return rows.map((r) => r.user_id); + } + + /** DWC3 — `saveSettings`'s previous-host read: `SELECT url FROM dawarich_connections WHERE user_id = ?`. */ + async getUrl(userId: number): Promise { + const row = await this.qb('c') + .select(['c.url']) + .where({ user: userId }) + .execute<{ url: string | null } | undefined>('get', false); + return row?.url ?? null; + } + + /** + * DWC4 — `saveSettings`'s upsert: `INSERT INTO dawarich_connections + * (user_id, url, allow_insecure_tls, sync_enabled, updated_at) VALUES (?, + * ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET url = + * excluded.url, allow_insecure_tls = excluded.allow_insecure_tls, + * sync_enabled = excluded.sync_enabled, updated_at = CURRENT_TIMESTAMP`. + * Single-column-PK upsert, `onConflictFields: ['user']`. + * `onConflictMergeFields` narrowed to exactly the four columns the legacy + * statement's own `SET` list touches (`OauthConsentsRepository.upsertGrant`'s + * precedent for the unconditional `updated_at` stamp; + * `VacayUserYearsRepository.upsertCarriedOver`'s precedent for narrowing + * the merge set) — `api_key`/`capabilities`/`last_sync_*` are untouched on + * conflict and default to NULL/`'never'` on a genuine INSERT, matching the + * legacy statement's own column list exactly. + */ + async upsertConnection(userId: number, data: { url: string | null; allowInsecureTls: boolean; syncEnabled: boolean }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { + user: userId, + url: data.url, + allow_insecure_tls: data.allowInsecureTls ? 1 : 0, + sync_enabled: data.syncEnabled ? 1 : 0, + updated_at: currentTimestamp(platform), + }, + { onConflictFields: ['user'], onConflictAction: 'merge', onConflictMergeFields: ['url', 'allow_insecure_tls', 'sync_enabled', 'updated_at'] }, + ); + } + + /** + * DWC5/DWC7 — `saveSettings`'s key write/scrub, one statement text: + * `UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?`. + * **SECURITY**: `apiKey` is the already-encrypted TEXT (or `null` to + * scrub), passed through untouched (R7). + */ + async setApiKey(userId: number, apiKey: string | null): Promise { + await this.nativeUpdate({ user: userId }, { api_key: apiKey }); + } + + /** + * DWC6 — `saveSettings`'s host-moved reset: `UPDATE dawarich_connections + * SET capabilities = NULL, last_sync_state = 'never', last_sync_error = + * NULL, last_sync_at = NULL WHERE user_id = ?`. + */ + async resetSyncState(userId: number): Promise { + await this.nativeUpdate({ user: userId }, { capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); + } + + /** + * DWC8 — `saveSettings`'s address-cleared reset: `UPDATE + * dawarich_connections SET api_key = NULL, capabilities = NULL, + * last_sync_state = 'never', last_sync_error = NULL, last_sync_at = NULL + * WHERE user_id = ?` — {@link resetSyncState}'s four columns plus + * `api_key`, one statement (never two calls: the legacy text is a single + * UPDATE with five `SET` clauses). + */ + async clearForRemovedUrl(userId: number): Promise { + await this.nativeUpdate({ user: userId }, { api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); + } + + /** + * DWC9 — `disconnect`: `DELETE FROM dawarich_connections WHERE user_id = + * ?`. **PARITY**: does not cascade to `dawarich_visit_suggestions` — + * deliberate (the entity carries no relation from suggestions back to a + * connection at all, so there is nothing for a migration-level cascade to + * even attach to). + */ + async disconnect(userId: number): Promise { + await this.nativeDelete({ user: userId }); + } + + /** DWC10 — `storeCapabilities`: `UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`. */ + async storeCapabilities(userId: number, capabilitiesJson: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ user: userId }, { capabilities: capabilitiesJson, updated_at: currentTimestamp(platform) }); + } + + /** + * DWC11 — `recordSyncResult`: `UPDATE dawarich_connections SET + * last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = + * CURRENT_TIMESTAMP WHERE user_id = ?`. **NON-HTTP ENTRYPOINT** — called + * after every cron sync. + */ + async recordSyncResult(userId: number, data: { lastSyncAt: string; state: string; error: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { user: userId }, + { last_sync_at: data.lastSyncAt, last_sync_state: data.state, last_sync_error: data.error, updated_at: currentTimestamp(platform) }, + ); + } + + /** + * DSY1 — `dawarich-sync.service.ts#syncUser`'s in-flight-branch read, a + * narrower dup shape of DWC1: `SELECT last_sync_state FROM + * dawarich_connections WHERE user_id = ?`. Lives here (not on + * `DawarichVisitSuggestionsRepository`) because it reads this table. + */ + async getLastSyncState(userId: number): Promise { + const row = await this.qb('c') + .select(['c.last_sync_state']) + .where({ user: userId }) + .execute<{ last_sync_state: string } | undefined>('get', false); + return row?.last_sync_state ?? null; + } +} diff --git a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts new file mode 100644 index 000000000..84ed763ce --- /dev/null +++ b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts @@ -0,0 +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'; + +/** `dawarich_visit_suggestions` — every scalar column, DWS6/DSY3's `SELECT *` shape. */ +export interface SuggestionRow { + id: number; + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + state: string; + target: string | null; + accepted_place_id: number | null; + accepted_journal_entry_id: number | null; + accepted_bucket_list_item_id: number | null; + matched_bucket_list_item_id: number | null; + source_hash: string; + accepted_hash: string | null; + source_missing_at: string | null; + first_seen_at: string; + last_seen_at: string; +} +const _suggestionRowKeys: AssertRowKeys = true; + +/** DWS1/DWS3's joined shape — `SuggestionRow` plus the two `LEFT JOIN`-derived columns. */ +export interface SuggestionJoinRow extends SuggestionRow { + trip_title: string | null; + matched_bucket_name: string | null; +} + +/** The narrow `dawarich_visit_suggestions`/`trips`/`bucket_list` shape DWS1/DWS3's join needs. */ +interface SuggestionJoinKyselyDB { + dawarich_visit_suggestions: SuggestionRow; + trips: { id: number; title: string | null }; + bucket_list: { id: number; name: string }; +} + +/** The narrow `dawarich_visit_suggestions` shape a bare `SELECT *` read needs (DWS6/DSY3). */ +interface SuggestionAllKyselyDB { + dawarich_visit_suggestions: SuggestionRow; +} + +/** + * The narrow `dawarich_visit_suggestions`/`journey_entries` shape DWS2's + * `reopenOrphaned` needs. `journey_entries` is 3g's table, read cross-domain + * over `this.kysely()` — the same "a repository may query any table" + * convention `JourneyEntriesRepository`'s own cross-domain reads + * (`listStatsTrips`/`listSuggestedTrips`) already establish — rather than a + * `JourneyEntriesRepository.existsById` call: 3g's Task 4 (the survivors + * task that would add that method) had not landed at the time this task + * ran, and appending to a Journey* repository is outside this task's file + * set (owned by that concurrent implementer). A single self-contained + * `NOT EXISTS` subquery inside the SAME `UPDATE` also preserves the legacy + * statement's one-round-trip shape more faithfully than a JS-side + * per-row `existsById` loop would have. + */ +interface ReopenOrphanedKyselyDB { + dawarich_visit_suggestions: SuggestionRow; + journey_entries: { id: number }; +} + +/** The narrow shape DSY7's window-candidates read needs. */ +interface CandidateRow { + id: number; + source_visit_id: string; + state: string; +} + +/** The narrow shape DSY11's wish-holders read needs. */ +interface HolderRow { + id: number; + source_visit_id: string; + lat: number | null; + lng: number | null; + duration_minutes: number; +} + +/** + * `dawarich_visit_suggestions` — one row per Dawarich stay TREK has synced + * in, reviewable by the user it belongs to. Depends on + * `DawarichConnectionsRepository` (via `DawarichService`) for credentials; + * reaches `journey_entries` (3g), `places` (3c, additive `PlacesRepository + * .setSource`) and `bucket_list` (3f, additive `BucketListRepository` + * methods) cross-domain. + */ +export class DawarichVisitSuggestionsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('dawarich_visit_suggestions as s') + .leftJoin('trips as t', 't.id', 's.trip_id') + .leftJoin('bucket_list as b', 'b.id', 's.matched_bucket_list_item_id') + .selectAll('s') + .select(['t.title as trip_title', 'b.name as matched_bucket_name']); + } + + /** + * DWS1 — `list`: `SELECT s.*, t.title AS trip_title, b.name AS + * matched_bucket_name FROM dawarich_visit_suggestions s LEFT JOIN trips t + * LEFT JOIN bucket_list b WHERE (dynamic: s.user_id=? [+ s.trip_id=?] [+ + * s.state=?]) ORDER BY s.started_at DESC, s.id DESC`. **SECURITY**: + * `user_id` is scoped in SQL, never filtered after — these rows are + * someone's location history. + */ + async list(userId: number, filter: { tripId?: number; state?: string }): Promise { + let q = this.joinedQuery().where('s.user_id', '=', userId); + if (filter.tripId !== undefined) q = q.where('s.trip_id', '=', filter.tripId); + if (filter.state) q = q.where('s.state', '=', filter.state); + const rows = await q.orderBy('s.started_at', 'desc').orderBy('s.id', 'desc').execute(); + return rows as SuggestionJoinRow[]; + } + + /** + * DWS2 — `reopenOrphaned`: put back into review every acceptance whose + * result no longer exists. Returns the affected-row count (unused by the + * caller today — `list`'s own re-select after this write is what the + * service reads — but kept for parity with every other write method here). + */ + async reopenOrphaned(userId: number): Promise { + const result = await this.kysely() + .updateTable('dawarich_visit_suggestions') + .set({ + state: 'new', + target: null, + accepted_hash: null, + accepted_place_id: null, + accepted_journal_entry_id: null, + accepted_bucket_list_item_id: null, + }) + .where('user_id', '=', userId) + .where('state', '=', 'accepted') + .where((eb) => + eb.or([ + eb.and([eb('target', '=', 'place'), eb('accepted_place_id', 'is', null)]), + eb.and([eb('target', '=', 'bucket_list'), eb('accepted_bucket_list_item_id', 'is', null)]), + eb.and([ + eb('target', '=', 'journal'), + eb.or([ + eb('accepted_journal_entry_id', 'is', null), + eb.not( + eb.exists( + eb + .selectFrom('journey_entries') + .select('id') + .whereRef('journey_entries.id', '=', 'dawarich_visit_suggestions.accepted_journal_entry_id'), + ), + ), + ]), + ]), + ]), + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0); + } + + /** DWS3 — `getOne`: single-row variant of {@link list}'s join. */ + async getOne(userId: number, id: number): Promise { + const row = await this.joinedQuery().where('s.id', '=', id).where('s.user_id', '=', userId).executeTakeFirst(); + return (row as SuggestionJoinRow) ?? null; + } + + /** DWS4 — `setState`'s guard read: `SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?`. */ + async findStateForUser(id: number, userId: number): Promise<{ id: number; state: string } | null> { + const row = await this.qb('s') + .select(['s.id', 's.state']) + .where({ id, user: userId }) + .execute<{ id: number; state: string } | undefined>('get', false); + return row ?? null; + } + + /** DWS5 — `setState`: `UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?`. */ + async setState(id: number, state: string): Promise { + await this.nativeUpdate({ id }, { state }); + } + + /** + * DWS6/DSY3 — `SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND + * user_id = ?` (`accept`'s guard) and `WHERE user_id = ? AND + * source_visit_id = ?` (`syncTripWindow`'s existing-row read), one shape + * with a different WHERE — {@link findForUser}/{@link findByUserAndVisit}. + * Kysely `selectAll()` rather than a MikroORM `qb` read: every + * `accepted_*`/`trip_id`/`matched_bucket_list_item_id` column is a + * `persist(false)` shadow FK on this entity, and a plain `SELECT *` over + * the physical table sidesteps that trap entirely rather than needing a + * `columnRef`/full-entity-load workaround for each one. + */ + async findForUser(id: number, userId: number): Promise { + const row = await this.kysely() + .selectFrom('dawarich_visit_suggestions') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + return (row as SuggestionRow) ?? null; + } + + /** DSY3 — `syncTripWindow`'s existing-row read: `SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?`. Implies `UNIQUE(user_id, source_visit_id)` (the entity's own `uniques`). */ + async findByUserAndVisit(userId: number, sourceVisitId: string): Promise { + const row = await this.kysely() + .selectFrom('dawarich_visit_suggestions') + .selectAll() + .where('user_id', '=', userId) + .where('source_visit_id', '=', sourceVisitId) + .executeTakeFirst(); + return (row as SuggestionRow) ?? null; + } + + /** DSY4 — `syncTripWindow`'s not-existing branch: the 17-column `INSERT`, `state` fixed to `'new'`. Returns the new row's id. */ + async insertVisit(data: { + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + source_hash: string; + first_seen_at: string; + last_seen_at: string; + }): Promise { + return await this.insert({ + user: data.user_id, + source_visit_id: data.source_visit_id, + trip: data.trip_id, + name: data.name, + lat: data.lat, + lng: data.lng, + started_at: data.started_at, + ended_at: data.ended_at, + duration_minutes: data.duration_minutes, + local_date: data.local_date, + source_status: data.source_status, + confidence: data.confidence, + confidence_band: data.confidence_band, + country_code: data.country_code, + state: 'new', + source_hash: data.source_hash, + first_seen_at: data.first_seen_at, + last_seen_at: data.last_seen_at, + }); + } + + /** DSY5 — `syncTripWindow`'s existing, `state='new'` branch: the full 15-column overwrite (a stay nobody has acted on yet). */ + async updateNewVisit( + id: number, + data: { + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + source_hash: string; + trip_id: number | null; + last_seen_at: string; + }, + ): Promise { + await this.nativeUpdate( + { id }, + { + name: data.name, + lat: data.lat, + lng: data.lng, + started_at: data.started_at, + ended_at: data.ended_at, + duration_minutes: data.duration_minutes, + local_date: data.local_date, + source_status: data.source_status, + confidence: data.confidence, + confidence_band: data.confidence_band, + country_code: data.country_code, + source_hash: data.source_hash, + source_missing_at: null, + trip: data.trip_id, + last_seen_at: data.last_seen_at, + }, + ); + } + + /** + * DSY6 — `syncTripWindow`'s existing, accepted/dismissed branch: `UPDATE + * dawarich_visit_suggestions SET source_hash = ?, source_missing_at = + * NULL, last_seen_at = ? WHERE id = ?`. **PARITY**: "nothing the user can + * see is rewritten". + */ + async refreshHash(id: number, sourceHash: string, lastSeenAt: string): Promise { + await this.nativeUpdate({ id }, { source_hash: sourceHash, source_missing_at: null, last_seen_at: lastSeenAt }); + } + + /** DSY7 — `flagMissing`'s candidates read: `SELECT id, source_visit_id, state FROM dawarich_visit_suggestions WHERE user_id = ? AND trip_id = ? AND local_date >= ? AND local_date <= ?`. **PARITY-CRITICAL**: `local_date` string bounding — see the SERVICE's own doc comment on why. */ + async listCandidatesForWindow(userId: number, tripId: number, from: string, to: string): Promise { + return await this.qb('s') + .select(['s.id', 's.source_visit_id', 's.state']) + .where({ user: userId, trip: tripId, local_date: { $gte: from, $lte: to } }) + .execute('all', false); + } + + /** DSY8 — `flagMissing`'s delete branch (only unseen `'new'`-state rows): `DELETE FROM dawarich_visit_suggestions WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSY9 — `flagMissing`'s stamp branch: `UPDATE dawarich_visit_suggestions + * SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?` — + * preserves the FIRST-missing timestamp. `coalesceParam` as an UPDATE-SET + * value (its own documented shape), not a filter key. + */ + async stampMissingIfUnset(id: number, stamp: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { source_missing_at: coalesceParam(platform, 'source_missing_at', stamp) }); + } + + /** + * DWS11 — `markAccepted`: `UPDATE dawarich_visit_suggestions SET + * state='accepted', target=?, accepted_place_id=?, + * accepted_journal_entry_id=?, accepted_bucket_list_item_id=?, + * accepted_hash=source_hash WHERE id=?`. **`accepted_hash = source_hash` + * is a self-column copy** — `columnRef(platform, 'source_hash')`, a + * column-REFERENCE expression read at UPDATE time, never a bound + * parameter: binding the row's CURRENT `source_hash` as a literal would + * freeze it at the moment this method was CALLED, not at the moment the + * UPDATE actually commits — wrong the instant a concurrent sync run + * changes `source_hash` between the two. `acceptedPlace`/ + * `acceptedBucketListItem` (relation properties, not their `persist(false)` + * `_id` mirrors) for the two FK writes; `accepted_journal_entry_id` is a + * plain persisted integer (journey entries carry no FK back from this + * table) written directly. + */ + async markAccepted( + id: number, + data: { target: string; placeId: number | null; journalEntryId: number | null; bucketItemId: number | null }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + state: 'accepted', + target: data.target, + acceptedPlace: data.placeId, + accepted_journal_entry_id: data.journalEntryId, + acceptedBucketListItem: data.bucketItemId, + accepted_hash: columnRef(platform, 'source_hash'), + }, + ); + } + + /** DSY11 — `claimWish`'s holders read: `SELECT id, source_visit_id, lat, lng, duration_minutes FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? AND source_visit_id <> ?`. */ + async listHoldersOfWish(userId: number, wishId: number, excludeSourceVisitId: string): Promise { + return await this.qb('s') + .select(['s.id', 's.source_visit_id', 's.lat', 's.lng', 's.duration_minutes']) + .where({ user: userId, matchedBucketListItem: wishId, source_visit_id: { $ne: excludeSourceVisitId } }) + .execute('all', false); + } + + /** DSY12 — `claimWish` (same TX as {@link assignWishHolder}, order matters): `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?` — clears every previous holder. */ + async clearWishHolders(userId: number, wishId: number): Promise { + await this.nativeUpdate({ user: userId, matchedBucketListItem: wishId }, { matchedBucketListItem: null }); + } + + /** DSY13 — `claimWish`: `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?` — assigns the winner. Called AFTER {@link clearWishHolders} in the same transaction, never before. */ + async assignWishHolder(userId: number, sourceVisitId: string, wishId: number): Promise { + await this.nativeUpdate({ user: userId, source_visit_id: sourceVisitId }, { matchedBucketListItem: wishId }); + } + + /** DWS14 — `matchedStayStart`: `SELECT started_at FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? ORDER BY started_at DESC LIMIT 1`. */ + async matchedStayStart(userId: number, itemId: number): Promise { + const row = await this.qb('s') + .select(['s.started_at']) + .where({ user: userId, matchedBucketListItem: itemId }) + .orderBy({ started_at: 'desc' }) + .limit(1) + .execute<{ started_at: string } | undefined>('get', false); + return row?.started_at ?? null; + } +} diff --git a/server/src/db/repositories/DayAccommodations.repository.ts b/server/src/db/repositories/DayAccommodations.repository.ts new file mode 100644 index 000000000..039dd224a --- /dev/null +++ b/server/src/db/repositories/DayAccommodations.repository.ts @@ -0,0 +1,540 @@ +import type { DayAccommodations } from '../entities/DayAccommodations.entity'; +import { coalesceOverride, columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `day_accommodations` row exactly as AC34 (`AccommodationsService + * .getAccommodation`) re-selects it — every scalar column of the entity + * (`a.*`), the trip-scoping guard every write path (REST update/delete, MCP, + * RPC) re-reads through. + */ +export interface DayAccommodationRow { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + created_at: string | null; +} + +const _dayAccommodationRowKeys: AssertRowKeys = true; + +/** AC2's projection: {@link DayAccommodationRow} plus the linked place's display fields. */ +export interface DayAccommodationWithPlaceRow extends DayAccommodationRow { + place_name: string | null; + place_address: string | null; + place_image: string | null; + place_lat: number | null; + place_lng: number | null; +} + +/** AC3's projection: {@link DayAccommodationWithPlaceRow} plus the linked booking's title — one row per linked `reservations` row (the LEFT JOIN fans out; parity keeps the duplicates, AC3's own ruling). */ +export interface DayAccommodationListRow extends DayAccommodationWithPlaceRow { + reservation_title: string | null; +} + +/** + * Kysely typing for AC3 (`listForTripWithPlaceAndBooking`) — `reservations + * .accommodation_id` is a bare `p.text().nullable()` column with no FK to + * `day_accommodations` (§18.1 of the inventory, the same gap + * `ReservationsRepository.joinedQuery` documents from the other side), so + * the QueryBuilder's relation-path `.join()` cannot express `LEFT JOIN + * reservations r ON r.accommodation_id = a.id` at all. + */ +interface DayAccommodationListKyselyDB { + day_accommodations: { + id: number; + // `number | string` — `ReservationsRepository.listForTrip`'s own + // `trip_id` widening: the raw-bind flexibility a `.where(col, '=', + // value)` call keeps regardless of how loosely its value is typed + // (program rule 23 bans a raw SQL-text condition, not a loosely-typed + // bound value). + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + created_at: string | null; + }; + places: { + id: number; + name: string; + address: string | null; + image_url: string | null; + lat: number | null; + lng: number | null; + }; + reservations: { + accommodation_id: string | null; + title: string; + }; +} + +/** + * Plan 3d Task 2 (`ReservationsService`'s `day_accommodations` writes — RS27, + * RS29, RS30, RS37–RS40, RS42, RS43, RS46, RS47 — plus the RS21 dispatch + * union's `'day_accommodations'` arm). This is the reservations-owned SUBSET + * of the repository: `days.service.ts` DY19/DY20/DY22 and `places.service.ts` + * PL16 (Plan 3d Task 3) add methods here too — the class is shared, per the + * inventory's own note (§7) — Task 3 appends rather than replaces. + * + * `day_accommodations` has no MikroORM relation to `reservations` + * (`reservations.accommodation_id` is a bare `p.text().nullable()` column, + * no FK — Task 0's R10 finding) — every method here that only reaches this + * table stays on the QueryBuilder/`nativeUpdate`/`insert`, never Kysely: the + * TEXT-vs-INTEGER join lives in `ReservationsRepository`, not here. + */ +export class DayAccommodationsRepository extends TrekRepository { + /** + * RS27/RS40 (`ReservationsService.createInTx`/`updateInTx`'s auto-create + * branch) — `INSERT INTO day_accommodations (trip_id, place_id, + * start_day_id, end_day_id, check_in, check_out, confirmation) VALUES + * (?×7)`. Seven columns only — no `check_in_end`, no `notes` (unlike + * `AccommodationsService`'s own AC30, a 9-column insert — a DIFFERENT + * method, D4). The caller passes already-decided values (the `|| null` + * coercions stay in the service); `em.insert()` returns the generated PK. + */ + async insertBookingStay(input: { + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + confirmation: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + place: input.place_id, + startDay: input.start_day_id, + endDay: input.end_day_id, + check_in: input.check_in, + check_out: input.check_out, + confirmation: input.confirmation, + }); + } + + /** + * RS29/RS42 — `UPDATE day_accommodations SET check_in = COALESCE(?, + * check_in), check_in_end = COALESCE(?, check_in_end), check_out = + * COALESCE(?, check_out) WHERE id = ?`. `coalesceOverride` (value-side, a + * bound `?` FIRST, the column the fallback — the new value wins) through + * `qb().update()` — `nativeUpdate`'s `EntityData` shape doesn't accept a + * raw fragment value (`DayAssignmentsRepository.shiftOrderFrom`'s + * precedent). + */ + async patchTimes(id: number, check_in: string | null, check_in_end: string | null, check_out: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ + check_in: coalesceOverride(platform, check_in, 'check_in'), + check_in_end: coalesceOverride(platform, check_in_end, 'check_in_end'), + check_out: coalesceOverride(platform, check_out, 'check_out'), + }) + .where({ id }) + .execute('run'); + } + + /** RS30/RS43 — `UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?`. */ + async patchConfirmation(id: number, confirmation: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ confirmation: coalesceOverride(platform, confirmation, 'confirmation') }) + .where({ id }) + .execute('run'); + } + + /** + * RS37/RS46 — `SELECT id FROM day_accommodations WHERE id = ? AND trip_id + * = ?`. `id`/`trip_id: number` (typed filter, program rule 23): both + * callers (`updateInTx`'s re-link scoping check, `remove`'s own-stay + * check) pass the SAME `toRowId`-parsed trip id the service method itself + * resolved once at its gate. + */ + async existsInTrip(id: number, trip_id: number): Promise { + const row = await this.qb('a') + .select(['a.id']) + .where({ id, trip: trip_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** RS38 — `SELECT check_in FROM day_accommodations WHERE id = ?`. */ + async getCheckIn(id: number): Promise { + const row = await this.qb('a') + .select(['a.check_in']) + .where({ id }) + .execute<{ check_in: string | null } | undefined>('get', false); + return row?.check_in; + } + + /** + * RS39 — `UPDATE day_accommodations SET place_id = ?, start_day_id = ?, + * end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = + * ?`. Six columns (no `check_in_end`/`notes`, unlike `AccommodationsService + * .moveStay`'s own write) — the caller's already-decided values, written + * verbatim (the `PlacesRepository.updatePlace`/PL11 precedent: no SQL-side + * COALESCE here, the legacy statement itself has none for these columns). + */ + async updateFromBooking(id: number, write: { + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + confirmation: string | null; + }): Promise { + await this.nativeUpdate({ id }, { + place: write.place_id, + startDay: write.start_day_id, + endDay: write.end_day_id, + check_in: write.check_in, + check_out: write.check_out, + confirmation: write.confirmation, + }); + } + + /** RS47 — `DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?`, run AFTER `accommodations.dropStayStops` (ordering load-bearing, kept in the service). `id`/`trip_id: number` (typed filter, program rule 23; rule 21 — the SAME `toRowId`-parsed trip id `existsInTrip`'s gate read used). */ + async deleteInTrip(id: number, trip_id: number): Promise { + await this.nativeDelete({ id, trip: trip_id }); + } + + /** + * RS21's `'day_accommodations'` dispatch arm (`ReservationsService + * .referencesOutsideTrip`'s `elsewhere('day_accommodations', + * data.accommodation_id)` — `SELECT trip_id FROM day_accommodations WHERE + * id = ?`). A missing row is not an offender (the caller's own `!!row && + * String(row.trip_id) !== String(tripId)` check, preserved in the + * service) — this returns `undefined` on a miss, never throws. + */ + async getTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('a') + .select([columnRef(platform, 'a.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`, `days.service.ts` DY19/DY20/DY22, + // `places.service.ts` PL16) — appended after Task 2's own methods above, + // per this task's file-ownership rule ("the class is shared... Task 3 + // appends rather than replaces"). + // --------------------------------------------------------------------------- + + /** + * AC2 (`AccommodationsService.getAccommodationWithPlace`) — `SELECT a.*, + * p.name as place_name, p.address as place_address, p.image_url as + * place_image, p.lat as place_lat, p.lng as place_lng FROM + * day_accommodations a LEFT JOIN places p ON a.place_id = p.id WHERE a.id + * = ?`. The re-select every stay write answers with, post-commit. + */ + async findWithPlace(id: number): Promise { + // `{ 'a.id': id }`, not a bare `{ id }` (`DayAssignmentsRepository + // .findWithPlaceAndCategory`'s precedent): the LEFT JOIN to `places` + // means an unqualified `id` key is ambiguous between `a.id` and `p.id`. + return await this.qb('a') + .leftJoin('a.place', 'p') + .select(['a.*', 'p.name as place_name', 'p.address as place_address', 'p.image_url as place_image', 'p.lat as place_lat', 'p.lng as place_lng']) + .where({ 'a.id': id }) + .execute('get', false); + } + + /** + * AC3 (`AccommodationsService.listAccommodations`) — AC2's projection plus + * `r.title as reservation_title` via `LEFT JOIN reservations r ON + * r.accommodation_id = a.id`, `WHERE a.trip_id = ? ORDER BY a.created_at + * ASC`. Kysely (the reservations join, see {@link DayAccommodationListKyselyDB}). + * Fans out one row per linked booking — the legacy statement's own shape + * (the delete path documents more than one booking per stay is legal) — + * parity keeps the duplicates, no `DISTINCT`. + */ + async listForTripWithPlaceAndBooking(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('reservations as r', 'r.accommodation_id', 'a.id') + .selectAll('a') + .select([ + 'p.name as place_name', + 'p.address as place_address', + 'p.image_url as place_image', + 'p.lat as place_lat', + 'p.lng as place_lng', + 'r.title as reservation_title', + ]) + .where('a.trip_id', '=', trip_id) + .orderBy('a.created_at', 'asc') + .execute(); + return rows as DayAccommodationListRow[]; + } + + /** + * AC34 (`AccommodationsService.getAccommodation`) — `SELECT * FROM + * day_accommodations WHERE id = ? AND trip_id = ?`, the trip-scoping + * guard every write path (REST update/delete, MCP, RPC) re-reads through. + * `id`/`trip_id: number` (rule 21 — the SERVICE parses both ONCE via + * `toRowId` before calling this). + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.qb('a') + .select(['a.*']) + .where({ id, trip: trip_id }) + .execute('get', false); + } + + /** + * AC30 (`AccommodationsService.createAccommodation`) — `INSERT INTO + * day_accommodations (trip_id, place_id, start_day_id, end_day_id, + * check_in, check_in_end, check_out, confirmation, notes) VALUES (?×9)`. + * Nine columns — a DIFFERENT method from Task 2's own `insertBookingStay` + * (seven columns, no `check_in_end`/`notes`, D4). The caller passes + * already-decided values (the `|| null` coercions stay in the service); + * `em.insert()` returns the generated PK (R6's `lastInsertRowid` + * replacement). + */ + async insertStay(input: { + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + place: input.place_id, + startDay: input.start_day_id, + endDay: input.end_day_id, + check_in: input.check_in, + check_in_end: input.check_in_end, + check_out: input.check_out, + confirmation: input.confirmation, + notes: input.notes, + }); + } + + /** + * AC36 (`AccommodationsService.updateAccommodation`) — `UPDATE + * day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, + * check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = + * ? WHERE id = ?`. Eight columns, written verbatim — the caller's + * `fields.x !== undefined ? fields.x : existing.x` presence sentinel + * (note: NO `|| null` here, unlike AC30 — the legacy statement itself has + * none for these columns, so `''` is stored as `''`). + */ + async updateStay(id: number, write: { + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + }): Promise { + await this.nativeUpdate({ id }, { + place: write.place_id, + startDay: write.start_day_id, + endDay: write.end_day_id, + check_in: write.check_in, + check_in_end: write.check_in_end, + check_out: write.check_out, + confirmation: write.confirmation, + notes: write.notes, + }); + } + + /** + * AC44 (`AccommodationsService.deleteAccommodation`) — `DELETE FROM + * day_accommodations WHERE id = ?`. No trip scoping (the legacy statement + * has none either — the caller already scoped `id` via AC34's guard, + * rule 21). + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DY19 (`DaysService.assertNoInvertedAccommodation`) — `SELECT a.id, + * s.day_number AS start_no, e.day_number AS end_no FROM day_accommodations + * a JOIN days s ON a.start_day_id = s.id JOIN days e ON a.end_day_id = + * e.id WHERE a.trip_id = ?`. Both `days` joins are declared relations + * (`startDay`/`endDay`), so the QueryBuilder expresses this directly. + */ + async listStartEndDayNumbers(trip_id: number): Promise<{ id: number; start_no: number; end_no: number }[]> { + return await this.qb('a') + .join('a.startDay', 's') + .join('a.endDay', 'e') + .select(['a.id', 's.day_number as start_no', 'e.day_number as end_no']) + .where({ trip: trip_id }) + .execute<{ id: number; start_no: number; end_no: number }[]>('all', false); + } + + /** + * DY20 (`DaysService.resyncAccommodationDays`) — `SELECT id, + * start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id + * = ?`. `check_in` seats the stay's stop on the day it is carried to + * (night-seat.ts). `start_day_id`/`end_day_id` are `persist(false)` mirrors + * of the `startDay`/`endDay` relations — a bare select would drop them (the + * `RoadtripDayBoundariesRepository` trap, Task 1's report), so both go + * through `columnRef`. + */ + async listForResync(trip_id: number): Promise<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('a') + .select(['a.id', columnRef(platform, 'a.start_day_id').as('start_day_id'), columnRef(platform, 'a.end_day_id').as('end_day_id'), 'a.check_in']) + .where({ trip: trip_id }) + .execute<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]>('all', false); + } + + /** + * DY41 (`DayRemovalService.cancelStays`) — `SELECT id FROM + * day_accommodations WHERE trip_id = ? AND (start_day_id = ? OR end_day_id + * = ?) ORDER BY id`: the stays that check in or out on a day being deleted. + */ + async listIdsCheckingInOrOutOn(trip_id: number, day_id: number): Promise { + const rows = await this.qb('a') + .select(['a.id']) + .where({ trip: trip_id, $or: [{ startDay: day_id }, { endDay: day_id }] }) + .orderBy({ id: 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** DY22 (`DaysService.resyncAccommodationDays`) — `UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?`. */ + async setDayRange(id: number, start_day_id: number, end_day_id: number): Promise { + await this.nativeUpdate({ id }, { startDay: start_day_id, endDay: end_day_id }); + } + + /** + * PL16 (`PlacesService.cancelStaysAt`) — `SELECT id FROM day_accommodations + * WHERE trip_id = ? AND place_id = ?`. Reached from `PlacesService` through + * `AccommodationsService` (already injected there for the cascade itself), + * not a new repository dependency of `PlacesService`'s own (R4 — keeps the + * trip scoping AC44's own unscoped delete relies on, inventory §14.3). + */ + async listForPlace(trip_id: number, place_id: number): Promise<{ id: number }[]> { + return await this.qb('a') + .select(['a.id']) + .where({ trip: trip_id, place: place_id }) + .execute<{ id: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 6 (`trips.service.ts::copy`) — additive, appended after + // Task 3's own methods above, per this task's file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * TP55 (`trips.service.ts::copy`'s day_accommodations read) — `SELECT * + * FROM day_accommodations WHERE trip_id = ?`, no `ORDER BY` (matching the + * legacy statement exactly — each row is independently remapped and + * inserted, so read order doesn't affect the copy). {@link + * DayAccommodationRow} is already this table's full-column shape (AC34's + * own `findInTrip` projection), reused here rather than declared again. + */ + async listAllForTrip(trip_id: number): Promise { + return await this.qb('a') + .select(['a.*']) + .where({ trip: trip_id }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // RoadtripPlanService.context (`stays` read) — RPL3 + // --------------------------------------------------------------------------- + + /** + * RPL3 (`RoadtripPlanService.context`'s `stays` read) — `SELECT a.id, + * a.place_id, a.start_day_id, a.end_day_id, a.check_in, a.check_out, + * p.name AS place_name, p.lat AS place_lat, p.lng AS place_lng, (SELECT + * MIN(r.id) FROM reservations r WHERE r.accommodation_id = a.id) AS + * reservation_id FROM day_accommodations a LEFT JOIN places p ON p.id = + * a.place_id WHERE a.trip_id = ? ORDER BY a.id`. Every stay of the trip in + * id order, the order the night-bookend rule reads them in. Kysely for the + * same reason as AC3: `reservations.accommodation_id` has no relation to + * this table (see {@link DayAccommodationListKyselyDB}). + */ + async listRoadtripStays(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.place_id as place_id', + 'a.start_day_id as start_day_id', + 'a.end_day_id as end_day_id', + 'a.check_in as check_in', + 'a.check_out as check_out', + 'p.name as place_name', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb + .selectFrom('reservations as r') + .select((eb2) => eb2.fn.min('r.id').as('m')) + .whereRef('r.accommodation_id', '=', 'a.id') + .as('reservation_id'), + ]) + .where('a.trip_id', '=', trip_id) + .orderBy('a.id', 'asc') + .execute(); + return rows as RoadtripStayRow[]; + } +} + +/** + * RPL3's row: a booked night as the road trip reads it, the stay, the days it spans, + * where it is and the earliest booking linked to it. The rule reads these for the hotel + * at the edges of the days around the night (`seatNightBookends`, as a `BookendStay`); + * get_roadtrip_context reports them as they are, with the check-in beside the check-out. + */ +export interface RoadtripStayRow { + id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + place_name: string | null; + place_lat: number | null; + place_lng: number | null; + reservation_id: number | null; +} + +/** Kysely typing for RPL3 (`listRoadtripStays`). */ +interface RoadtripStaysKyselyDB { + day_accommodations: { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + }; + places: { + id: number; + name: string; + lat: number | null; + lng: number | null; + }; + reservations: { + id: number; + accommodation_id: string | null; + }; +} diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts new file mode 100644 index 000000000..ca893d5c1 --- /dev/null +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -0,0 +1,989 @@ +import type { Platform } from '@mikro-orm/core'; +import type { DayAssignments } from '../entities/DayAssignments.entity'; +import { coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * The DY1/DY3/AS1/AS3 assignment-with-place projection row (Plan 3c Task 2's + * report is the contract for Task 3, which consumes this unchanged): + * + * ```sql + * SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + * p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + * COALESCE(da.assignment_time, p.place_time) as place_time, + * COALESCE(da.assignment_end_time, p.end_time) as end_time, + * p.duration_minutes, p.notes as place_notes, + * p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, + * p.website, p.phone, p.stop_type, p.fill_percent, + * c.name as category_name, c.color as category_color, c.icon as category_icon + * FROM day_assignments da + * JOIN places p ON da.place_id = p.id + * LEFT JOIN categories c ON p.category_id = c.id + * WHERE + * [ORDER BY da.order_index ASC, da.created_at ASC] + * ``` + * + * `da.*` selects every scalar column of `DayAssignments` (`place_id` among + * them, the physical FK column) and then `p.id as place_id` selects it again + * under the same output key — the legacy statement did this too, and + * better-sqlite3's row object keeps the LAST column with a given name, so + * `place_id` on the wire is always `p.id`, not `da.place_id` (they agree by + * the JOIN condition regardless). Nullability here is the physical column's, + * not `AssignmentRow`'s (types.ts) narrower claims (rule 16) — a place with + * no category genuinely nulls `category_name`/`category_color`/ + * `category_icon` through the LEFT JOIN even though `Categories.name` itself + * is NOT NULL. + */ +export interface AssignmentWithPlaceRow extends DayAssignmentRow { + place_name: string; + place_description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + place_currency: string | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + place_notes: string | null; + image_url: string | null; + transport_mode: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + amap_poi_id: string | null; + website: string | null; + phone: string | null; + stop_type: string | null; + fill_percent: number | null; + category_name: string | null; + category_color: string | null; + category_icon: string | null; +} + +/** + * {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) — + * {@link AssignmentWithPlaceRow} minus the six columns a public share link + * must not leak. + */ +export type SharePublicAssignmentRow = Omit< + AssignmentWithPlaceRow, + 'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent' +>; + +/** + * A `day_assignments` row exactly as `AS12` (`AssignmentsService + * .getAssignmentForTrip`) re-selects it — every scalar column of the entity + * (`da.*`), scoped by a two-hop join to `days` for the trip check (Task 3). + */ +export interface DayAssignmentRow { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + notes: string | null; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + created_at: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + end_day: number; + accommodation_id: number | null; + route_excluded: number; +} + +const _dayAssignmentRowKeys: AssertRowKeys = true; + +/** AS18's stop, exactly as `AssignmentsService.sortDayByTime`/`chronoOrder` read it. */ +export interface DayStopRow { + id: number; + order_index: number | null; + effective_time: string | null; + located: number; +} + +/** AC7's row ({@link DayAssignmentsRepository.listSeatRows}), `night-seat.ts`'s `SeatRow`. */ +export interface DaySeatRow { + id: number; + order_index: number | null; + at: string | null; + night_id: number | null; + located: number; +} + +/** + * `day_assignments`/`places`/`day_accommodations`'s shape for AS16 + * (`effectiveStart`) and AS18 (`listForTimeSort`), an explicit `TDB` type + * argument for `this.kysely()` (per `WebauthnChallengesRepository + * .claimChallenge`'s docstring — entity-metadata inference is not what a + * hand-written statement wants). There is no MikroORM relation from + * `DayAssignments` to `DayAccommodations` — `accommodation_id` is a plain + * `p.integer()` column on this entity, not a `manyToOne` (`day_accommodations` + * is Plan 3d's table; adding the relation means touching + * `src/db/entities/DayAssignments.entity.ts`, out of this task's scope) — so + * the QueryBuilder's relation-path `.join()` cannot express the LEFT JOIN at + * all. Kysely joining the physical table name directly is the sanctioned + * next step down D3's escape-hatch order (`find/findOne → nativeUpdate → + * qb() → em.getKysely()`). + */ +interface AssignmentTimeSortKyselyDB { + day_assignments: { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + assignment_time: string | null; + accommodation_id: number | null; + created_at: string | null; + }; + places: { + id: number; + place_time: string | null; + lat: number | null; + lng: number | null; + }; + day_accommodations: { + id: number; + check_in: string | null; + }; +} + +/** + * RPL2's row, exactly as `RoadtripPlanService.context`'s old local `VisitRow` + * interface declared it — the stay the visit stands for on its check-in day + * (never `a.accommodation_id`, see the method docstring), and the checkout + * day's `day_number`, both `null` for a visit with no linked stay. + * `accommodation_id` is the stay whose booking wrote this stop (null for one + * the traveller placed), and `order_index` is the stop's slot in the day plan + * the carrier terminals are seated against. + */ +export interface RoadtripVisitRow { + id: number; + day_id: number; + order_index: number; + place_id: number; + name: string; + lat: number | null; + lng: number | null; + time: string | null; + end_time: string | null; + duration_minutes: number | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + stop_type: string | null; + fill_percent: number | null; + accommodation_id: number | null; + stay_id: number | null; + check_in: string | null; + check_out: string | null; + checkout_day: number | null; +} + +/** + * `day_assignments`/`days`/`places`/`day_accommodations`'s shape for RPL2 + * (`listRoadtripVisits`), the same Kysely-typed-DB-interface shape + * `AssignmentTimeSortKyselyDB` above uses. `days` is joined twice under two + * aliases (`d`, `checkout`) — both resolve against this one `days` entry. + */ +interface RoadtripVisitsKyselyDB { + day_assignments: { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + accommodation_id: number | null; + created_at: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + }; + days: { + id: number; + trip_id: number; + day_number: number; + }; + places: { + id: number; + name: string; + lat: number | null; + lng: number | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + stop_type: string | null; + fill_percent: number | null; + }; + day_accommodations: { + id: number; + place_id: number; + start_day_id: number; + end_day_id: number | null; + check_in: string | null; + check_out: string | null; + }; +} + +export class DayAssignmentsRepository extends TrekRepository { + /** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */ + private assignmentWithPlaceSelect(platform: Platform) { + return [ + 'da.*', + 'p.id as place_id', + 'p.name as place_name', + 'p.description as place_description', + 'p.lat', + 'p.lng', + 'p.address', + // `category_id` is `persist(false)` on `Places` — a bare `'p.category_id'` + // string selects nothing (not a real property name), and `'p.category'` + // (the owning relation) is ALSO the joined alias here (`c`), so MikroORM + // absorbs the select into the join and returns `c__id`, not `category_id` + // (`McpTokensRepository.listAllWithUsername`'s `t.user`/`user_id` + // docstring is the same trap, one join away from this one). `columnRef` + // selects the literal physical column, keyed `category_id`. + columnRef(platform, 'p.category_id'), + 'p.price', + 'p.currency as place_currency', + coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'), + coalesce(platform, 'da.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes', + 'p.notes as place_notes', + 'p.image_url', + 'p.transport_mode', + 'p.google_place_id', + 'p.google_ftid', + 'p.osm_id', + 'p.amap_poi_id', + 'p.website', + 'p.phone', + 'p.stop_type', + 'p.fill_percent', + 'c.name as category_name', + 'c.color as category_color', + 'c.icon as category_icon', + ] as const; + } + + /** + * AS1 (`assignments.service.ts::getAssignmentWithPlace`, Task 3): the + * projection filtered to one assignment, no ORDER BY (a single row). + */ + async findWithPlaceAndCategory(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.id': id }) + .execute('get', false); + return row ?? null; + } + + /** + * DY1 (`days.service.ts::getAssignmentsForDay`) and AS3 + * (`assignments.service.ts::listDayAssignments`, Task 3): the projection + * for one day, `ORDER BY da.order_index ASC, da.created_at ASC`. + */ + async listForDay(day_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.day': day_id }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + /** + * DY3 (`days.service.ts::list`): the same projection for several days at + * once (`WHERE da.day_id IN (...)`), same ORDER BY. Empty-array + * short-circuit before any query, as the legacy dynamic-`IN` builder did. + */ + async listWithPlaceAndCategory(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.day': { $in: day_ids } }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + /** + * `share.service.ts:288` SH9 (`getSharedTripData`'s share_map assignment + * read) — the SAME projection {@link assignmentWithPlaceSelect} builds, + * minus six internal-id/routing columns a public share link must never + * leak (`google_place_id`, `google_ftid`, `osm_id`, `amap_poi_id`, + * `stop_type`, `fill_percent`) — never widen this list to match the + * private `listWithPlaceAndCategory` projection above. + */ + private publicAssignmentSelect(platform: Platform) { + return [ + 'da.*', + 'p.id as place_id', + 'p.name as place_name', + 'p.description as place_description', + 'p.lat', + 'p.lng', + 'p.address', + columnRef(platform, 'p.category_id'), + 'p.price', + 'p.currency as place_currency', + coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'), + coalesce(platform, 'da.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes', + 'p.notes as place_notes', + 'p.image_url', + 'p.transport_mode', + 'p.website', + 'p.phone', + 'c.name as category_name', + 'c.color as category_color', + 'c.icon as category_icon', + ] as const; + } + + /** SH9 — same WHERE/ORDER BY shape as {@link listWithPlaceAndCategory}, narrower projection. */ + async listPublicForShare(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.category', 'c') + .select(this.publicAssignmentSelect(platform)) + .where({ 'da.day': { $in: day_ids } }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended after Task 2's own + // methods above, per this task's file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * AS9 (`assignmentExistsInDay`) — `SELECT da.id FROM day_assignments da + * JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND + * d.trip_id = ?`. + * + * **`id: number`, not `number | string` (Task 3 review H1, absorbed in + * Task 4 — `task-3-review.md` §9.1):** this method's ONLY caller + * (`AssignmentsService.assignmentExistsInDay`) now runs `toRowId` on the + * route's raw `id` BEFORE calling this — a non-canonical id (`"3 "`, + * `"3.0"`) must resolve to "not found" in the SERVICE gate, not pass this + * statement's own SQLite text/integer affinity match and then disagree + * with `deleteAssignment`'s `toRowId(id)!` downstream (the H1 regression). + * `day_id`/`trip_id` keep their `number | string` raw-bind scoping — + * `deleteAssignment` never reads either back, so there is no gate-vs-write + * id to protect there. + */ + async existsInDay(id: number, day_id: number | string, trip_id: number | string): Promise { + const row = await this.qb('da') + .join('da.day', 'd') + .select(['da.id']) + .where('da.id = ? AND da.day_id = ? AND d.trip_id = ?', [id, day_id, trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * AS12 (`getAssignmentForTrip`) — `SELECT da.* FROM day_assignments da + * JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`. + * + * **`id: number`, not `number | string` (Task 3 review H1, absorbed in + * Task 4 — `task-3-review.md` §9.1):** this method's ONLY caller + * (`AssignmentsService.getAssignmentForTrip`) now runs `toRowId` on the + * route/tool's raw `id` BEFORE calling this, the same H1 fix + * `existsInDay` above applies — every mutation behind this gate + * (`updateTime`/`setEndDay`/`updateNotes`/`setLegTransportMode`/ + * `setIncomingLegTransportMode`/`setParticipants`/`moveAssignment`) + * converts `id` with its own `toRowId(id)!`, and `ItineraryRpc.unassign` + * reads `existing.day_id` off THIS result (a genuine re-select, not only + * an existence guard) — so the id this read resolves must be the same one + * every one of those writes uses. `trip_id` keeps its `number | string` + * raw-bind scoping, unchanged. + */ + async findInTrip(id: number, trip_id: number | string): Promise { + const row = await this.qb('da') + .join('da.day', 'd') + .select(['da.*']) + .where('da.id = ? AND d.trip_id = ?', [id, trip_id]) + .execute('get', false); + return row ?? undefined; + } + + /** + * AS6 (`createAssignment`'s append-position read) — `SELECT + * MAX(order_index) as max FROM day_assignments WHERE day_id = ?`, read as + * the highest row by an ordered `limit(1)` (`DaysRepository.maxDayNumber`'s + * precedent — SQLite sorts `NULL` last under `DESC`, so an all-NULL or + * empty table folds the same way `MAX()` ignoring every `NULL` does). + * Returns the raw nullable value, NOT folded to `0`/`-1` here: the + * service's explicit `maxOrder !== null ? maxOrder : -1` check (AS6's + * ruling) needs to tell "no rows" from a stored `0` order_index, which a + * `?? -1` fold would collapse identically — unlike `DaysRepository + * .maxDayNumber`, this repository method must NOT pre-fold the null case. + */ + async maxOrderIndex(day_id: number): Promise { + const row = await this.qb('da') + .select('da.order_index') + .where({ day: day_id }) + .orderBy({ order_index: 'desc' }) + .limit(1) + .execute<{ order_index: number | null } | undefined>('get', false); + return row?.order_index ?? null; + } + + /** + * AS7 — `UPDATE day_assignments SET order_index = order_index + 1 WHERE + * day_id = ? AND order_index >= ?`, a column-from-column update + * (`InviteTokensRepository.incrementUsedCount`'s `columnIncrementedBy` + * precedent — `nativeUpdate`'s `EntityData` shape doesn't accept a raw + * fragment value, so this goes through `qb().update()` instead). + */ + async shiftOrderFrom(day_id: number, from: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', 1) }) + .where({ day: day_id, order_index: { $gte: from } }) + .execute('run'); + } + + /** + * AS8 — `INSERT INTO day_assignments (day_id, place_id, order_index, + * notes, accommodation_id) VALUES (?, ?, ?, ?, ?)`. The caller passes + * already-decided values (the `notes || null`/`accommodationId ?? null` + * coercions and the clamped `orderIndex` stay in the service, the same + * split `DaysRepository.createDay`'s docstring describes) — `em.insert()` + * returns the generated PK (R6's `lastInsertRowid` replacement). + */ + async insertAssignment(input: { + day_id: number; + place_id: number; + order_index: number; + notes: string | null; + accommodation_id: number | null; + }): Promise { + return await this.insert({ + day: input.day_id, + place: input.place_id, + order_index: input.order_index, + notes: input.notes, + accommodation_id: input.accommodation_id, + }); + } + + /** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * AS11 (`reorderAssignments`, day-scoped: `UPDATE day_assignments SET + * order_index = ? WHERE id = ? AND day_id = ?`) and AS19 + * (`sortDayByTime`'s per-row write, NOT day-scoped: `UPDATE + * day_assignments SET order_index = ? WHERE id = ?`) — one method, both + * signatures, per the inventory's ruling ("keep both signatures"): + * `day_id` omitted (`undefined`) reproduces AS19's unscoped statement. + */ + async setOrderIndex(id: number, day_id: number | undefined, order_index: number): Promise { + const where = day_id !== undefined ? { id, day: day_id } : { id }; + await this.nativeUpdate(where, { order_index }); + } + + /** AS13 (`moveAssignment`'s source-day read) — `SELECT day_id FROM day_assignments WHERE id = ?`. */ + async getDayId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + // `day_id` is `persist(false)` (the physical FK mirror of the `day` + // relation) — a bare `'da.day_id'` select hits the same trap + // `assignmentWithPlaceSelect`'s `category_id` comment documents; + // `columnRef` selects the literal physical column instead. + const row = await this.qb('da') + .select([columnRef(platform, 'da.day_id').as('day_id')]) + .where({ id }) + .execute<{ day_id: number } | undefined>('get', false); + return row?.day_id; + } + + /** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */ + async moveToDay(id: number, day_id: number, order_index: number): Promise { + await this.nativeUpdate({ id }, { day: day_id, order_index }); + } + + /** + * AS16 (`updateTime`'s pre-write read) — `SELECT da.day_id, + * COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start FROM + * day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN + * day_accommodations acc ON acc.id = da.accommodation_id WHERE da.id = ?`. + * Kysely (see `AssignmentTimeSortKyselyDB`'s docstring for why: no ORM + * relation to `day_accommodations` exists to `.join()` through). + */ + async effectiveStart(id: number): Promise<{ day_id: number; start: string | null } | undefined> { + const row = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id') + .select((eb) => ['da.day_id as day_id', eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('start')]) + .where('da.id', '=', id) + .executeTakeFirst(); + return row as { day_id: number; start: string | null } | undefined; + } + + /** + * AS17 — `UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?`. + * The falsy-clears-to-null coercion (`placeTime || null`) stays in the + * service (AS17's ruling); this writes the already-decided values. + */ + async setTimes(id: number, assignment_time: string | null, assignment_end_time: string | null): Promise { + await this.nativeUpdate({ id }, { assignment_time, assignment_end_time }); + } + + /** + * AS18 (`sortDayByTime`'s day-wide read) — `SELECT da.id, da.order_index, + * COALESCE(da.assignment_time, p.place_time, acc.check_in) as + * effective_time, (p.lat IS NOT NULL AND p.lng IS NOT NULL) as located + * FROM day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN + * day_accommodations acc ON acc.id = da.accommodation_id WHERE da.day_id + * = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC`. Same + * Kysely escape hatch as `effectiveStart`. The computed boolean column is + * expressed with the expression builder's own comparison/`.and()` + * combinators — never the banned `sql` tag — and SQLite returns it as the + * same `0`/`1` integer the legacy raw statement did. + */ + async listForTimeSort(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id') + .select((eb) => [ + 'da.id as id', + 'da.order_index as order_index', + eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('effective_time'), + eb.and([eb('p.lat', 'is not', null), eb('p.lng', 'is not', null)]).as('located'), + ]) + .where('da.day_id', '=', day_id) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .orderBy('da.id', 'asc') + // `$castTo` (Task 9 fix wave, B-L1): Kysely infers `located` as its own + // `SqlBool` type from `eb.and(...)`, not the `0`/`1` integer + // `DayStopRow.located` declares (the value SQLite actually returns for + // a boolean expression) — a straight `as DayStopRow[]` is rejected as + // an unrelated-type cast, which is what forced the `as unknown as` + // bridge. `$castTo` retypes the builder itself before `execute()` + // runs, so the result is `DayStopRow[]` with no intermediate `unknown`. + .$castTo() + .execute(); + return rows; + } + + /** AS24 — `UPDATE day_assignments SET end_day = ? WHERE id = ?`. */ + async setEndDay(id: number, end_day: number): Promise { + await this.nativeUpdate({ id }, { end_day }); + } + + /** AS25 — `UPDATE day_assignments SET notes = ? WHERE id = ?`. */ + async setNotes(id: number, notes: string | null): Promise { + await this.nativeUpdate({ id }, { notes }); + } + + /** AS26 — `UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?`. */ + async setLegMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { leg_transport_mode: mode }); + } + + /** AS27 — `UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?`. */ + async setIncomingLegMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { incoming_leg_transport_mode: mode }); + } + + /** + * AS32 (`AssignmentsService.clearDay`, #2470) — `SELECT id FROM + * day_assignments WHERE day_id = ?`, no ORDER BY (unlike AC1's + * {@link listIdsForDay}). + */ + async listIdsToClear(day_id: number): Promise { + const rows = await this.qb('da') + .select(['da.id']) + .where({ day: day_id }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** AS33 (`AssignmentsService.clearDay`, #2470) — `DELETE FROM day_assignments WHERE day_id = ?`. */ + async deleteForDay(day_id: number): Promise { + await this.nativeDelete({ day: day_id }); + } + + /** AS34 (`AssignmentsService.setRouteExcluded`, #2532) — `UPDATE day_assignments SET route_excluded = ? WHERE id = ?`. */ + async setRouteExcluded(id: number, route_excluded: number): Promise { + await this.nativeUpdate({ id }, { route_excluded }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService.exportGpx`) — appended per the task-4 + // brief's "task-3-report.md" pointer ("DayAssignmentsRepository for the + // PL30 GPX itinerary read if it fits there"). Unlike AS16/AS18, this join + // (`day_assignments` -> `days`, `day_assignments` -> `places`) is fully + // expressible through the entity's own declared relations (`da.day`, + // `da.place`) — no `day_accommodations`-shaped gap — so the QueryBuilder is + // used directly rather than Kysely. + // --------------------------------------------------------------------------- + + /** + * PL30 (`places.service.ts::exportGpx`) — `SELECT d.day_number, d.date, + * d.title, p.name, p.lat, p.lng FROM days d JOIN day_assignments da ON + * da.day_id = d.id JOIN places p ON p.id = da.place_id WHERE d.trip_id = ? + * AND p.lat IS NOT NULL AND p.lng IS NOT NULL ORDER BY d.day_number, + * da.order_index` — rooted here on `day_assignments` instead of `days` + * (the legacy statement's own `FROM` root) because both joins the legacy + * statement needs (`day_assignments -> days`, `day_assignments -> places`) + * are single hops FROM this entity, while `days` has no direct relation to + * `places`. Same result set, same ordering; the root alias choice is not + * observable from the row shape. + */ + async listItineraryForGpx(trip_id: string | number): Promise<{ + day_number: number; date: string | null; title: string | null; + name: string; lat: number; lng: number; + }[]> { + return this.qb('da') + .join('da.day', 'd') + .join('da.place', 'p') + .select(['d.day_number', 'd.date', 'd.title', 'p.name', 'p.lat', 'p.lng']) + .where('d.trip_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL', [trip_id]) + .orderBy({ 'd.day_number': 'asc', 'da.order_index': 'asc' }) + .execute<{ day_number: number; date: string | null; title: string | null; name: string; lat: number; lng: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP48 (`trips.service.ts::copy`'s assignments read) — `SELECT da.* FROM + * day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?`, + * no `ORDER BY` (matching the legacy statement, which relies on SQLite's + * own rowid-ascending scan order — the copy loop's insertion order tracks + * the source's own, same reasoning as `PlacesRepository.listAllForTrip`). + */ + async listAllForTrip(trip_id: number | string): Promise { + return this.qb('da') + .join('da.day', 'd') + .select(['da.*']) + .where('d.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP49 (`trips.service.ts::copy`'s assignment INSERT) — the 10-column + * `INSERT INTO day_assignments (day_id, place_id, order_index, notes, + * reservation_status, reservation_notes, reservation_datetime, + * assignment_time, assignment_end_time, end_day) VALUES (...)`. A + * different column set from AS8's `insertAssignment` (5 columns: `day_id`, + * `place_id`, `order_index`, `notes`, `accommodation_id` — no reservation/ + * time/end_day fields, and `accommodation_id` is stamped separately here, + * AFTER the bookings loop, via {@link setAccommodation} below, TP57) — the + * copy carries a source assignment's reservation snapshot and timing + * verbatim, values already fully resolved by the caller (`a.end_day ?? 0` + * stays the service's own decision, `DaysRepository.createDay`'s split). + */ + async insertAssignmentCopy(input: { + day_id: number; + place_id: number; + order_index: number | null; + notes: string | null; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + end_day: number; + }): Promise { + return await this.insert({ + day: input.day_id, + place: input.place_id, + order_index: input.order_index, + notes: input.notes, + reservation_status: input.reservation_status, + reservation_notes: input.reservation_notes, + reservation_datetime: input.reservation_datetime, + assignment_time: input.assignment_time, + assignment_end_time: input.assignment_end_time, + end_day: input.end_day, + }); + } + + /** + * TP57 (`trips.service.ts::copy`'s post-bookings-loop stamp) — `UPDATE + * day_assignments SET accommodation_id = ? WHERE id = ?`. Run once per + * copied stay-stop, AFTER the `day_accommodations` bookings are copied + * (Plan 3d's own table — the bookings loop itself stays raw, `// TPn — + * Plan 3d`), because only then does the copied accommodation's id exist + * to stamp on. `accommodation_id` is a plain `p.integer()` column on this + * entity, not a relation (`AssignmentTimeSortKyselyDB`'s docstring), so a + * typed `nativeUpdate` partial reaches it directly. + */ + async setAccommodation(id: number, accommodation_id: number): Promise { + await this.nativeUpdate({ id }, { accommodation_id }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own + // file-ownership rule ("all additive methods on Days/Places/DayAssignments + // repositories"). + // --------------------------------------------------------------------------- + + /** + * RPL2 (`roadtrip-plan.service.ts::context`'s `visits` read) — the + * cluster's most dialect-specific SELECT (inventory §17b): + * + * ```sql + * SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng, + * COALESCE(a.assignment_time, p.place_time) AS time, + * COALESCE(a.assignment_end_time, p.end_time) AS end_time, + * p.duration_minutes, a.end_day, + * a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, + * a.accommodation_id, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day + * FROM day_assignments a + * JOIN days d ON d.id = a.day_id + * JOIN places p ON p.id = a.place_id + * LEFT JOIN day_accommodations stay + * ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) + * LEFT JOIN days checkout ON checkout.id = stay.end_day_id + * WHERE d.trip_id = ? + * ORDER BY d.day_number, a.order_index, a.created_at + * ``` + * + * A correlated scalar subquery INSIDE a `LEFT JOIN … ON` clause — no + * QueryBuilder shape expresses this (§17b), and no MikroORM relation + * connects a place+check-in-day pair to its `day_accommodations` row (the + * stay is matched by `place_id`+`start_day_id`, not by + * `a.accommodation_id` — deliberately: the inventory's own RPL2 note), + * so this is `this.kysely()` from the start, same escape-hatch order as + * `effectiveStart`/`listForTimeSort` above. `days` is + * joined twice under two different aliases (`d`, `checkout`) — ordinary + * Kysely self-join aliasing, no separate interface entry needed since both + * resolve against the same `days` table shape. + */ + async listRoadtripVisits(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as a') + .innerJoin('days as d', 'd.id', 'a.day_id') + .innerJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('day_accommodations as stay', (join) => + join.on('stay.id', '=', (eb) => + eb + .selectFrom('day_accommodations as da2') + .select('da2.id') + .whereRef('da2.place_id', '=', 'p.id') + .whereRef('da2.start_day_id', '=', 'd.id') + .orderBy('da2.id', 'asc') + .limit(1), + ), + ) + .leftJoin('days as checkout', 'checkout.id', 'stay.end_day_id') + .select((eb) => [ + 'a.id as id', + 'a.day_id as day_id', + 'a.order_index as order_index', + 'a.place_id as place_id', + 'p.name as name', + 'p.lat as lat', + 'p.lng as lng', + eb.fn.coalesce('a.assignment_time', 'p.place_time').as('time'), + eb.fn.coalesce('a.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes as duration_minutes', + 'a.end_day as end_day', + 'a.leg_transport_mode as leg_transport_mode', + 'a.incoming_leg_transport_mode as incoming_leg_transport_mode', + 'p.stop_type as stop_type', + 'p.fill_percent as fill_percent', + 'a.accommodation_id as accommodation_id', + 'stay.id as stay_id', + 'stay.check_in as check_in', + 'stay.check_out as check_out', + 'checkout.day_number as checkout_day', + ]) + .where('d.trip_id', '=', trip_id) + .orderBy('d.day_number', 'asc') + .orderBy('a.order_index', 'asc') + .orderBy('a.created_at', 'asc') + .$castTo() + .execute(); + return rows; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** AC1 (`AccommodationsService.announceMirror`) — `SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index`, once per touched day. */ + async listIdsForDay(day_id: number): Promise { + const rows = await this.qb('da') + .select(['da.id']) + .where({ day: day_id }) + .orderBy({ order_index: 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** + * AC7 (`night-seat.ts`'s `SeatRow`, read by `AccommodationsService` and + * `DaysService` through `seatIndexWith`/`seatHolds`; `reseat-booked-nights.ts` + * runs the same statement raw inside migration 242) — `SELECT da.id, + * da.order_index, CASE WHEN other.id IS NULL THEN + * COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, + * other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS + * located FROM day_assignments da JOIN places p ON p.id = da.place_id LEFT + * JOIN day_accommodations other ON other.id = da.accommodation_id WHERE + * da.day_id = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC`. + * `locatedStopIdsWith` reads the via positions off these same rows + * (`located`), so a night and its vias are measured by one statement. + * Same Kysely escape hatch as {@link effectiveStart}/{@link listForTimeSort} + * (no ORM relation to `day_accommodations`); `located` and the `$castTo` + * follow `listForTimeSort`. + */ + async listSeatRows(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as other', 'other.id', 'da.accommodation_id') + .select((eb) => [ + 'da.id as id', + 'da.order_index as order_index', + eb + .case() + .when('other.id', 'is', null) + .then(eb.fn.coalesce('da.assignment_time', 'p.place_time')) + .else(eb.ref('other.check_in')) + .end() + .as('at'), + 'other.id as night_id', + eb.and([eb('p.lat', 'is not', null), eb('p.lng', 'is not', null)]).as('located'), + ]) + .where('da.day_id', '=', day_id) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .orderBy('da.id', 'asc') + .$castTo() + .execute(); + return rows; + } + + /** + * AC18/AC24 (`AccommodationsService.relocateOwnStop`/`mirrorStay`) — one + * method, two call shapes (D4): `SELECT id FROM day_assignments WHERE + * day_id = ? AND place_id = ? AND id != ?` (AC18, with `exclude_id`) and + * `SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?` + * (AC24, without). + */ + async existsForDayAndPlace(day_id: number, place_id: number, exclude_id?: number): Promise { + const query = this.qb('da').select(['da.id']).where({ day: day_id, place: place_id }); + if (exclude_id !== undefined) query.andWhere({ id: { $ne: exclude_id } }); + const row = await query.execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * AC19 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments + * SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?`, + * a column-from-column update (`columnIncrementedBy`, {@link shiftOrderFrom}'s + * precedent). + */ + async closeGap(day_id: number, from_index: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', -1) }) + .where({ day: day_id, order_index: { $gt: from_index } }) + .execute('run'); + } + + /** + * AC20 (`AccommodationsService.relocateOwnStop`) — `SELECT MAX(order_index) + * AS max FROM day_assignments WHERE day_id = ? AND id != ?`, read the same + * way {@link maxOrderIndex} is (an ordered `limit(1)`, null NOT pre-folded — + * the caller's own `max.max !== null ? max.max : -1` check needs to tell + * "no rows" from a stored `0`). + */ + async maxOrderIndexExcluding(day_id: number, exclude_id: number): Promise { + const row = await this.qb('da') + .select('da.order_index') + .where({ day: day_id, id: { $ne: exclude_id } }) + .orderBy({ order_index: 'desc' }) + .limit(1) + .execute<{ order_index: number | null } | undefined>('get', false); + return row?.order_index ?? null; + } + + /** AC21 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?`. */ + async relocate(id: number, day_id: number, place_id: number, order_index: number): Promise { + await this.nativeUpdate({ id }, { day: day_id, place: place_id, order_index }); + } + + /** + * AC22 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments + * SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? + * AND id != ?`, the same column-from-column shape as {@link shiftOrderFrom} + * with an extra exclusion. + */ + async shiftFromExcluding(day_id: number, from_index: number, exclude_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', 1) }) + .where({ day: day_id, order_index: { $gte: from_index }, id: { $ne: exclude_id } }) + .execute('run'); + } + + /** AC25 (`AccommodationsService.ownStops`) — `SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ?`. `day_id`/`place_id` are `persist(false)` relation mirrors — `columnRef`, the same trap {@link getDayId} documents. */ + async listOwnedByStay(accommodation_id: number): Promise<{ id: number; day_id: number; place_id: number; order_index: number | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .select(['da.id', columnRef(platform, 'da.day_id').as('day_id'), columnRef(platform, 'da.place_id').as('place_id'), 'da.order_index']) + .where({ accommodation_id }) + .execute<{ id: number; day_id: number; place_id: number; order_index: number | null }[]>('all', false); + } + + /** AC26 (`AccommodationsService.releaseStops`, `keepStop` branch) — `UPDATE day_assignments SET accommodation_id = NULL WHERE id = ?`. */ + async clearStay(id: number): Promise { + await this.nativeUpdate({ id }, { accommodation_id: null }); + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL3 (`FilesService.findForeignLinkTarget`'s assignment branch) — `SELECT + * 1 FROM day_assignments a JOIN days d ON a.day_id = d.id WHERE a.id = ? + * AND d.trip_id = ?`, re-expressed as "what trip does this row belong to" + * via its `day` relation (R12 — "DayAssignmentsRepository.findTripId via + * its days join"; `day_assignments` has no `trip_id` column of its own). + * The same join shape `ReservationsRepository.getAssignmentTripId` + * (RS22) already used from a DIFFERENT repository before this file was + * this task's own to append to — this copy lives here instead, per R12's + * "one method per target table, on that table's own repository" ruling. + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .join('da.day', 'd') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } +} diff --git a/server/src/db/repositories/DayNotes.repository.ts b/server/src/db/repositories/DayNotes.repository.ts new file mode 100644 index 000000000..2a97cf429 --- /dev/null +++ b/server/src/db/repositories/DayNotes.repository.ts @@ -0,0 +1,209 @@ +import type { DayNotes } from '../entities/DayNotes.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +export interface DayNoteRow { + id: number; + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + created_at: string | null; + color: string | null; +} + +const _dayNoteRowKeys: AssertRowKeys = true; + +export class DayNotesRepository extends TrekRepository { + /** + * The column set of the legacy INSERT: + * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)`. + * + * The caller passes already-coerced values — every field is required and is + * written verbatim. The legacy defaults (`icon || '📝'`, `sortOrder ?? 9999`, + * the trimmed text, `normalizeNoteColor(color)`) stay in + * `nest/day-notes/day-notes.service.ts`, which is where that rule lives. + * + * `created_at` is not in the column set: it is left to the column's + * `DEFAULT CURRENT_TIMESTAMP`. It alone would not need the re-read either — + * a `defaultRaw` column the insert does not name is in its `returning` + * clause. The re-read is there for the columns that are not: one this + * insert never names would otherwise be `undefined` on the entity and + * missing from the row. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`: `flush()` commits the *whole* unit of + * work of the request's `EntityManager`, not just this row. The read-back + * uses `disableIdentityMap: true` by the base class's default. + */ + async createNote(input: { + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + color: string | null; + }): Promise { + const id = await this.insert({ + day: input.day_id, + trip: input.trip_id, + text: input.text, + time: input.time, + icon: input.icon, + sort_order: input.sort_order, + color: input.color, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createNote: read-back after insert found no row'); + } + return toRow(inserted) as DayNoteRow; + } + + /** + * DY4 (`days.service.ts::list`) — `` SELECT * FROM day_notes WHERE day_id + * IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC ``. + * Empty-array short-circuit before any query, as the legacy dynamic-`IN` + * builder did. + */ + async listByDayIds(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const notes = await this.find({ day: { $in: day_ids } }, { orderBy: { sort_order: 'asc', created_at: 'asc' } }); + return notes.map((n) => toRow(n) as DayNoteRow); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP70 (`trips.service.ts::copy`'s day-notes read) — `SELECT * FROM + * day_notes WHERE trip_id = ?`, no `ORDER BY` (matching the legacy + * statement — the copy loop only needs `dayMap` to remap `day_id`, never + * relies on read order). + */ + async listByTrip(trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP71 (`trips.service.ts::copy`'s day-note INSERT) — `INSERT INTO + * day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, + * ?, ?, ?, ?)`. A narrower column set than `createNote`'s (no `color` — + * the legacy copy statement never names that column, so it is left to + * its own nullable-with-no-default, i.e. `NULL`, the same way `createNote`'s + * own docstring describes an unnamed column). No read-back: the copy loop + * discards the note's own id. + */ + async insertNoteCopy(input: { + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + }): Promise { + return await this.insert({ + day: input.day_id, + trip: input.trip_id, + text: input.text, + time: input.time, + icon: input.icon, + sort_order: input.sort_order, + }); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`day-notes.service.ts`) — additive: the remaining raw + // `this.dbs.all/get/run` statements ruling 8 mis-filed as already clean. + // --------------------------------------------------------------------------- + + /** + * `day-notes.service.ts::list` — `SELECT * FROM day_notes WHERE day_id = ? + * AND trip_id = ? ORDER BY sort_order ASC, created_at ASC`. Raw-bind + * (`number | string`, D4's T5 escape hatch, `DaysRepository.existsInTrip`'s + * precedent): the legacy statement binds the route's raw params with no + * `Number()`/`toRowId` conversion of its own. + */ + async listByDayAndTrip(day_id: number | string, trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.day_id = ? AND n.trip_id = ?', [day_id, trip_id]) + .orderBy({ sort_order: 'asc', created_at: 'asc' }) + .execute('all', false); + } + + /** + * `day-notes.service.ts::getNote` — `SELECT * FROM day_notes WHERE id = ? + * AND day_id = ? AND trip_id = ?`. Same raw-bind seam as + * {@link listByDayAndTrip}. + */ + async findByIdDayTrip(id: number | string, day_id: number | string, trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.id = ? AND n.day_id = ? AND n.trip_id = ?', [id, day_id, trip_id]) + .execute('get', false); + } + + /** + * `day-notes.service.ts::update` — `UPDATE day_notes SET text = ?, time = + * ?, icon = ?, sort_order = ?, color = ? WHERE id = ?` then the same + * `SELECT * FROM day_notes WHERE id = ?` re-select {@link createNote} uses. + * Every field is required (the caller — `DayNotesService.update` — + * already resolves each one's `fields.x !== undefined ? fields.x : + * current.x` fallback, same shape as `createNote`'s docstring). + */ + async updateNote(id: number | string, fields: { + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + color: string | null; + }): Promise { + await this.qb('n') + .update({ text: fields.text, time: fields.time, icon: fields.icon, sort_order: fields.sort_order, color: fields.color }) + .where('n.id = ?', [id]) + .execute('run'); + return await this.qb('n') + .select(['n.*']) + .where('n.id = ?', [id]) + .execute('get', false); + } + + /** `day-notes.service.ts::remove` — `DELETE FROM day_notes WHERE id = ?`. */ + async deleteById(id: number | string): Promise { + await this.qb('n') + .delete() + .where('n.id = ?', [id]) + .execute('run'); + } + + /** + * `public-api.service.ts::dayNotesByDay` — `SELECT day_id, text, time FROM + * day_notes WHERE trip_id = ? ORDER BY day_id ASC, sort_order ASC`. Kysely: + * `day_id` is a `persist(false)` mirror of the `day` relation + * (`DayNotes.entity.ts`) and is PROJECTED here, so a bare `qb().select([...])` + * would silently drop it (the `ShareTokensRepository`/`CollectionPlacesRepository` + * class-docstring trap). + */ + async listForPublicApi(trip_id: number): Promise<{ day_id: number; text: string; time: string | null }[]> { + return await this.kysely() + .selectFrom('day_notes') + .select(['day_id', 'text', 'time']) + .where('trip_id', '=', trip_id) + .orderBy('day_id', 'asc') + .orderBy('sort_order', 'asc') + .execute(); + } +} + +/** {@link DayNotesRepository.listForPublicApi}'s narrow `day_notes` shape. */ +interface DayNotesPublicApiKyselyDB { + day_notes: { day_id: number; trip_id: number; text: string; time: string | null; sort_order: number | null }; +} diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts new file mode 100644 index 000000000..9245d7eb6 --- /dev/null +++ b/server/src/db/repositories/Days.repository.ts @@ -0,0 +1,379 @@ +import type { Days } from '../entities/Days.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `days` row as the API emits it. */ +export interface DayRow { + id: number; + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + title: string | null; + default_transport_mode: string | null; +} + +const _dayRowKeys: AssertRowKeys = true; + +/** The DY26/DY29 reorder/insert projection: `SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number`. */ +export interface DayOrderRow { + id: number; + day_number: number; + date: string | null; +} + +/** The DY21 re-anchor lookup: `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */ +export interface DayIdAndNumberRow { + id: number; + day_number: number; +} + +/** RPL1's projection: `SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number`. */ +export interface PlanDayRow { + id: number; + day_number: number; + date: string | null; + title: string | null; + default_transport_mode: string | null; +} + +/** TP77's projection: a day as the shared planDayGrid reads it. `has_plan_items` is SQLite's 0/1. */ +export interface DayGridReadRow { + id: number; + day_number: number; + date: string | null; + has_plan_items: number; +} + +/** The narrow table shape TP77/TP78 read (`this.kysely()`'s typed `DB` argument). */ +interface DayGridKyselyDB { + days: { id: number; trip_id: number; day_number: number; date: string | null }; + day_assignments: { id: number; day_id: number }; + day_notes: { id: number; day_id: number }; + day_accommodations: { id: number; start_day_id: number; end_day_id: number }; +} + +export class DaysRepository extends TrekRepository { + /** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */ + async listByTrip(trip_id: number): Promise { + const days = await this.find({ trip: trip_id }, { orderBy: { day_number: 'asc' } }); + return days.map((d) => toRow(d) as DayRow); + } + + /** `SELECT * FROM days WHERE id = ? AND trip_id = ?` */ + async findInTrip(id: number, trip_id: number): Promise { + const day = await this.findOne({ id, trip: trip_id }); + return day ? (toRow(day) as DayRow) : undefined; + } + + /** + * CT1/CT2 (Plan 3j Task 5, `contributions/day-schedule.controller.ts` / + * `day-tints.controller.ts`, byte-identical text) — `SELECT id FROM days + * WHERE trip_id = ?`, the trip's own day-id set (used only for a + * membership check against a plugin-contributed `dayId`). + */ + async listIdsByTrip(trip_id: number): Promise { + const rows = await this.find({ trip: trip_id }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * MR9 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'day'` arm, + * R12's "one method per target table, no dynamic identifier dispatch" + * precedent — `PlacesRepository.findTripId`/`ReservationsRepository + * .findTripId`'s own docstrings) — `SELECT trip_id FROM days WHERE id = + * ?`. `trip_id` is a `persist(false)` mirror of the `trip` relation — + * `columnRef`, not a bare select (the program-wide trap). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('d') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * The trip's highest `day_number`, 0 when it has no days. + * + * The legacy statement was `SELECT MAX(day_number) ... WHERE trip_id = ?`; + * this reads the same value as the first row of a descending order, which + * needs no SQL function spelled outside `db/dialect/sql-functions.ts` and no + * entity hydrated into the identity map. + */ + async maxDayNumber(trip_id: number): Promise { + const row = await this.qb('d') + .select('d.day_number') + .where({ trip: trip_id }) + .orderBy({ day_number: 'desc' }) + .limit(1) + .execute<{ day_number: number | null } | undefined>('get', false); + return row?.day_number ?? 0; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)`. + * + * The caller passes already-coerced values — every field is required and is + * written verbatim, because a default or a coercion belongs to the service + * that owns the rule, not to the statement that stores it. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 fix + * round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: `flush()` + * commits the *whole* unit of work of the request's `EntityManager`, not + * just this row. The insert is followed by a read-back + * (`disableIdentityMap: true` by the base class's default), so the caller + * gets the stored row rather than what it asked to store — not optional: + * the insert's `returning` clause carries only the generated and + * `defaultRaw` columns, so a column this insert never names (`title`, + * `default_transport_mode`) would otherwise simply be missing from the + * row. + */ + async createDay(input: { + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + }): Promise { + const id = await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + notes: input.notes, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createDay: read-back after insert found no row'); + } + return toRow(inserted) as DayRow; + } + + /** + * `SELECT * FROM days WHERE id = ?` — DY7/DY10/DY12/DY32/DY36's shared + * re-select, one method for all five sites (DY32/DY36 read after their + * transaction resolves, so this is a fresh statement against committed + * rows, no `refresh` needed). + */ + async findById(id: number): Promise { + const day = await this.findOne({ id }); + return day ? (toRow(day) as DayRow) : undefined; + } + + /** + * DY9 — `UPDATE days SET notes = ?, title = ? WHERE id = ?`. The presence + * sentinel that decides `notes`/`title` (the legacy's asymmetric `||` + * vs. `??` coercion) stays in `DaysService.update`; this takes the final, + * already-decided values and writes them verbatim. + */ + async updateNotesAndTitle(id: number, notes: string | null, title: string | null): Promise { + await this.nativeUpdate({ id }, { notes, title }); + } + + /** DY11 — `UPDATE days SET default_transport_mode = ? WHERE id = ?` (#1281). */ + async setDefaultTransportMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { default_transport_mode: mode }); + } + + /** DY13 — `DELETE FROM days WHERE id = ?`, unscoped: the caller proved trip access. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DY26/DY29 — `SELECT id, day_number, date FROM days WHERE trip_id = ? + * ORDER BY day_number`, the read `reorder`/`insert` both take before + * opening their `uow.transactional` block (same text, two legacy methods, + * one repository method here). + */ + async listOrderedForReorder(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + /** DY27/DY30 — `UPDATE days SET day_number = ? WHERE id = ?` (the two-phase renumber's per-row write). */ + async setDayNumber(id: number, day_number: number): Promise { + await this.nativeUpdate({ id }, { day_number }); + } + + /** DY28/DY33 — `UPDATE days SET day_number = ?, date = ? WHERE id = ?`. */ + async setDayNumberAndDate(id: number, day_number: number, date: string | null): Promise { + await this.nativeUpdate({ id }, { day_number, date }); + } + + /** + * DY31/DY34 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, + * ?)` (no `notes` column named, unlike `createDay`'s DY6 — the row's + * `notes` is left to the column's own NULL default). Returns the inserted + * id (`em.insert()`'s returned PK, R6's `lastInsertRowid` replacement); + * the caller re-selects separately (DY32/DY36), after its transaction + * resolves, so no read-back happens here. + */ + async insertDay(input: { trip_id: number; day_number: number; date: string | null }): Promise { + return await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP39 (`trips.service.ts::copy`'s day INSERT) — `INSERT INTO days + * (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)`. A + * different column set from both `insertDay` (DY31/DY34, no `notes`/ + * `title`) and `createDay` (no `title`): the copy carries a source day's + * `notes` AND `title` verbatim, with no read-back — the caller only needs + * the generated id for `dayMap`. + */ + async insertDayCopy(input: { + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + title: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + notes: input.notes, + title: input.title, + }); + } + + /** DY21 — `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */ + async findByTripAndDate(trip_id: number, date: string): Promise { + const row = await this.qb('d') + .select(['d.id', 'd.day_number']) + .where({ trip: trip_id, date }) + .limit(1) + .execute('get', false); + return row ?? undefined; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own + // file-ownership rule ("all additive methods on Days/Places/DayAssignments + // repositories"). + // --------------------------------------------------------------------------- + + /** + * RPL1 (`roadtrip-plan.service.ts::context`) — `SELECT id, day_number, + * date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY + * day_number`. + */ + async listPlanDays(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.default_transport_mode']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended per the task's own + // file-ownership rule ("additive methods on Days/Places repositories if + // needed"). + // --------------------------------------------------------------------------- + + /** + * AS4 (`AssignmentsService.dayExists`) — `SELECT id FROM days WHERE id = ? + * AND trip_id = ?`. Raw-bind (`number | string`, D4's T5 escape hatch, + * `TripsRepository.findAccessible`'s precedent): the legacy guard binds + * the route's raw params with no `Number()`/`toRowId` conversion of its + * own, so this must accept and bind exactly what it's handed. + */ + async existsInTrip(id: number | string, trip_id: number | string): Promise { + const row = await this.qb('d') + .select(['d.id']) + .where('d.id = ? AND d.trip_id = ?', [id, trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`TripsService.generateDays`) — appended per the task's own + // file-ownership rule ("additive methods on Days/Places/Users repositories"). + // --------------------------------------------------------------------------- + + /** + * TP77 (`trips.service.ts::generateDays`, the shared planDayGrid input) — + * `SELECT d.id, d.day_number, d.date, EXISTS (SELECT 1 FROM day_assignments + * da WHERE da.day_id = d.id) OR EXISTS (SELECT 1 FROM day_notes dn WHERE + * dn.day_id = d.id) AS has_plan_items FROM days d WHERE d.trip_id = ?`. + * Ordered by `day_number` here where the legacy statement has no `ORDER BY`: + * harmless, the plan sorts by `day_number` itself. `$castTo` for the same + * reason `DayAssignmentsRepository.listForTimeSort` gives: Kysely types the + * `eb.or(...)` column as its own `SqlBool`, not the `0`/`1` SQLite returns. + */ + async listForDayGrid(trip_id: number): Promise { + return await this.kysely() + .selectFrom('days as d') + .select(['d.id', 'd.day_number', 'd.date']) + .select((eb) => + eb.or([ + eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.day_id', '=', 'd.id')), + eb.exists(eb.selectFrom('day_notes as dn').select('dn.id').whereRef('dn.day_id', '=', 'd.id')), + ]).as('has_plan_items'), + ) + .where('d.trip_id', '=', trip_id) + .orderBy('d.day_number', 'asc') + .$castTo() + .execute(); + } + + /** + * TP78 (`trips.service.ts::generateDays`) — `SELECT dac.start_day_id, + * dac.end_day_id FROM day_accommodations dac WHERE dac.start_day_id IN + * (SELECT id FROM days WHERE trip_id = ?) OR dac.end_day_id IN (SELECT id + * FROM days WHERE trip_id = ?)`: every stay touching one of the trip's days. + */ + async listDayGridStays(trip_id: number): Promise<{ start_day_id: number; end_day_id: number }[]> { + return await this.kysely() + .selectFrom('day_accommodations as dac') + .select(['dac.start_day_id', 'dac.end_day_id']) + .where((eb) => + eb.or([ + eb('dac.start_day_id', 'in', eb.selectFrom('days').select('id').where('trip_id', '=', trip_id)), + eb('dac.end_day_id', 'in', eb.selectFrom('days').select('id').where('trip_id', '=', trip_id)), + ]), + ) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::buildDays`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT id, day_number, date, title, notes FROM days WHERE trip_id = ? + * ORDER BY day_number ASC` — the same shape {@link listPlanDays} already + * establishes for this table, a narrower column set. + */ + async listForPublicApi(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.notes']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } +} + +/** {@link DaysRepository.listForPublicApi}'s projection. */ +export interface PublicApiDayProjectionRow { + id: number; + day_number: number; + date: string | null; + title: string | null; + notes: string | null; +} diff --git a/server/src/db/repositories/DemoRepository.ts b/server/src/db/repositories/DemoRepository.ts new file mode 100644 index 000000000..6190a99ff --- /dev/null +++ b/server/src/db/repositories/DemoRepository.ts @@ -0,0 +1,288 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Users } from '../entities/Users.entity'; +import type { NewAdminUserRow } from './Users.repository'; +import { AppSettings } from '../entities/AppSettings.entity'; +import { Trips } from '../entities/Trips.entity'; + +/** DMR1's projection (`resetDemoUser`'s pre-close credential read). */ +export interface DemoAdminCredentialsRow { + password_hash: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + avatar: string | null; +} + +/** DMR2's projection (`resetDemoUser`'s pre-close instance-API-key read) — a row per matched key, `value` preserved even when it is SQL NULL (unlike `AppSettingsRepository#getValues`, which drops a NULL-valued row from its Map; DMR6 re-writes exactly what DMR2 read, so a NULL must round-trip, not disappear). */ +export interface DemoInstanceKeyRow { + key: string; + value: string | null; +} + +/** + * DMS9's 13-column `places` insert tuple — kept as a tuple, not an object, + * so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto, + * Barcelona, New York) pass through unchanged: `trip_id, name, lat, lng, + * address, category_id, place_time, duration_minutes, notes, image_url, + * google_place_id, website, phone`. `category_id` is HARD-CODED 1-9 in the + * caller's data, coupled to `CategorySeeder`'s row order — a documented + * fragility (`db/orm.ts`), not something this repository fixes. + */ +export type NewDemoPlaceRow = readonly [ + tripId: number, + name: string, + lat: number, + lng: number, + address: string, + categoryId: number, + placeTime: string, + durationMinutes: number, + notes: string, + imageUrl: string | null, + googlePlaceId: string | null, + website: string | null, + phone: string | null, +]; + +/** + * Plan 3i Task 3 — `demo-seed.ts`'s 14 distinct statement texts (17 call + * sites) and `demo-reset.ts`'s reset-side statements (per R3's SAFE spike + * verdict, `task-0-report.md`), named ahead of this plan by the program + * brief's own rule 4. + * + * Not a `TrekRepository` — it backs no table and no entity of its + * own (it reaches `users`/`app_settings`/`trips` and, for the bulk + * example-trip seed, `days`/`places`/`day_assignments`/`packing_items`/ + * `budget_items`/`reservations`/`day_notes`/`trip_members`, none of which it + * "owns" the way `Users.repository.ts` owns `users`). Where an existing + * repository already has the exact shape (`UsersRepository#findByEmailExact`/ + * `#insertAdminCreatedUser`, `AppSettingsRepository#upsertOrReplace`) this + * repository composes it via `this.em.getRepository(Entity)` rather than + * duplicating the statement — `Users.repository.ts`/`AppSettings.repository.ts` + * are Plan 3i Task 1's territory this task and never edits. The remaining + * shapes (a `trips` count/list, the shared `trip_members` "insert or ignore" + * membership row, and the seven bulk INSERT loops for the three example + * trips) go through `connection.execute()` — rule 4's last-tier escape + * hatch, reserved for exactly this repository — because a prepared + * statement run in a loop over hand-written seed data is the natural shape + * here, and forcing ~90 rows of literal data through per-entity `insert()` + * calls (importing eight more entity classes this task does not own) buys + * nothing a raw, parameterised statement doesn't already give. + * + * Constructed directly (`new DemoRepository(em)`), never through Nest DI: + * its only two callers — `demo/demo-seed.ts` and `demo/demo-reset.ts` — are + * plain function modules, not Nest providers (the first Nest-DI-*shaped* + * home either has ever had, not a Nest-DI-*resolved* one), and obtain the + * `EntityManager` via `RequestContext.getEntityManager()` (the same static + * accessor MikroORM's own per-request middleware and + * `CronRegistrarService`'s `wrappedTick` populate) rather than constructor + * injection. No module registers this class as a provider. + */ +export class DemoRepository { + constructor(private readonly em: EntityManager) {} + + /** See `MaintenanceRepository`'s twin — throws outside a request context. */ + private validateRequestContext(): void { + this.em.getContext(); + } + + /** `this.em.getConnection().execute(sql, params, 'run')`, validated first — the one place every raw statement below funnels through. */ + private async run(sql: string, params: readonly unknown[]): Promise<{ insertId: number }> { + this.validateRequestContext(); + const result = await this.em.getConnection().execute<{ id: number }>(sql, [...params], 'run'); + return { insertId: Number(result.insertId) }; + } + + // --------------------------------------------------------------------- + // demo-seed.ts + // --------------------------------------------------------------------- + + /** + * DMS1 — `SELECT id FROM users WHERE email = ?`. Two call sites in + * `seedDemoData` (the admin lookup, the demo-user lookup) share this one + * method. Composes `UsersRepository#findByEmailExact` (case-sensitive, no + * guest filter, full row) rather than a new statement — exact shape match. + */ + async findUserByEmail(email: string): Promise<{ id: number } | null> { + const row = await this.em.getRepository(Users).findByEmailExact(email); + return row ? { id: row.id } : null; + } + + /** + * DMS2 — `INSERT INTO users (username, email, password_hash, role) VALUES + * (?, ?, ?, ?)`. Two call sites in `seedDemoData` (admin creation, demo-user + * creation) share this one method. Composes + * `UsersRepository#insertAdminCreatedUser` — byte-identical legacy text + * (AD4's own statement), which already leaves every other column + * (`first_seen_version`, `login_count`, …) to the entity's default, the + * same as the legacy INSERT's omitted columns fell back to the schema's + * `DEFAULT`. + */ + async createUser(row: NewAdminUserRow): Promise { + return await this.em.getRepository(Users).insertAdminCreatedUser(row); + } + + /** DMS3 — `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')`. Composes `AppSettingsRepository#upsertOrReplace`, the same dialect the legacy statement used. */ + async setAllowRegistrationFalse(): Promise { + await this.em.getRepository(AppSettings).upsertOrReplace('allow_registration', 'false'); + } + + /** DMS4 — `SELECT COUNT(*) as count FROM trips WHERE user_id = ?` (the "already seeded" idempotency check). */ + async countTripsByUser(userId: number): Promise { + this.validateRequestContext(); + return await this.em.getRepository(Trips).count({ user_id: userId }); + } + + /** DMS5 — `SELECT id FROM trips WHERE user_id = ?` (`ensureDemoMembership`'s trip list). */ + async listTripIdsByUser(userId: number): Promise { + this.validateRequestContext(); + const rows = await this.em.getRepository(Trips).find({ user_id: userId }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * DMS6 — `INSERT OR IGNORE INTO trip_members (trip_id, user_id, + * invited_by) VALUES (?, ?, ?)`. Shared between `ensureDemoMembership` + * (looped over an admin's existing trips) and `seedExampleTrips` (one call + * per newly seeded trip) — one repository method, called from both, per + * the legacy code's own dup-text shape. + */ + async addTripMember(tripId: number, userId: number, invitedBy: number): Promise { + await this.run('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)', [tripId, userId, invitedBy]); + } + + /** DMS7 — `INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)`. Returns the generated trip id. */ + async insertTrip(userId: number, title: string, description: string, startDate: string, endDate: string, currency: string): Promise { + const { insertId } = await this.run( + 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)', + [userId, title, description, startDate, endDate, currency], + ); + return insertId; + } + + /** DMS8 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`. Returns the generated day id. */ + async insertDay(tripId: number, dayNumber: number, date: string): Promise { + const { insertId } = await this.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', [tripId, dayNumber, date]); + return insertId; + } + + /** DMS9 — the 13-column `places` insert (see {@link NewDemoPlaceRow}). Returns the generated place id. */ + async insertPlace(row: NewDemoPlaceRow): Promise { + const { insertId } = await this.run( + 'INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + row, + ); + return insertId; + } + + /** DMS10 — `INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)`. */ + async insertDayAssignment(dayId: number, placeId: number, orderIndex: number): Promise { + await this.run('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)', [dayId, placeId, orderIndex]); + } + + /** DMS11 — `INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`. */ + async insertPackingItem(tripId: number, name: string, checked: number, category: string, sortOrder: number): Promise { + await this.run('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', [tripId, name, checked, category, sortOrder]); + } + + /** DMS12 — `INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertBudgetItem(tripId: number, category: string, name: string, totalPrice: number, persons: number, note: string | null): Promise { + await this.run('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)', [tripId, category, name, totalPrice, persons, note]); + } + + /** + * DMS13 — `INSERT INTO reservations (trip_id, day_id, title, + * reservation_time, confirmation_number, status, type, location) VALUES + * (?, ?, ?, ?, ?, ?, ?, ?)`. `reservationTime` carries the full date + * (`'2026-04-15T15:00'`), never a bare clock time — pinned parity, #1934. + */ + async insertReservation( + tripId: number, + dayId: number, + title: string, + reservationTime: string, + confirmationNumber: string, + status: string, + type: string, + location: string, + ): Promise { + await this.run( + 'INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [tripId, dayId, title, reservationTime, confirmationNumber, status, type, location], + ); + } + + /** DMS14 — `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertDayNote(dayId: number, tripId: number, text: string, time: string, icon: string, sortOrder: number): Promise { + await this.run('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [dayId, tripId, text, time, icon, sortOrder]); + } + + // --------------------------------------------------------------------- + // demo-reset.ts + // --------------------------------------------------------------------- + + /** + * DMR1 — `SELECT password_hash, maps_api_key, openweather_api_key, + * unsplash_api_key, avatar FROM users WHERE email = ?` (the pre-close + * credential read). Generic inherited `findOne` with a narrower `fields` + * projection — no new method on `Users.repository.ts` (Task 1's + * territory), same reasoning as `findUserByEmail` above. + */ + async getAdminCredentials(email: string): Promise { + const row = await this.em + .getRepository(Users) + .findOne({ email }, { fields: ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar'] }); + return row + ? { + password_hash: row.password_hash, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + avatar: row.avatar ?? null, + } + : null; + } + + /** + * DMR2 — `SELECT key, value FROM app_settings WHERE key IN + * ('maps_api_key', 'unsplash_api_key')` (the pre-close instance-API-key + * read). Generic inherited `find` — deliberately NOT + * `AppSettingsRepository#getValues`, which drops a NULL-valued row; this + * caller's own DMR6 counterpart re-writes exactly what it read. + */ + async getInstanceApiKeys(): Promise { + const rows = await this.em.getRepository(AppSettings).find({ key: { $in: ['maps_api_key', 'unsplash_api_key'] } }); + return rows.map((r) => ({ key: r.key ?? '', value: r.value ?? null })); + } + + /** + * DMR5 — `UPDATE users SET password_hash = ?, maps_api_key = ?, + * openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = + * ?` (the post-reopen credential restore). Generic inherited + * `nativeUpdate`, writing exactly these five columns — no `updated_at` + * bump, matching the legacy statement (unlike `UsersRepository`'s own + * `updateApiKeys`/`updateMapsKey`, which do touch it for a different + * caller). + */ + async restoreAdminCredentials(email: string, patch: DemoAdminCredentialsRow): Promise { + this.validateRequestContext(); + await this.em.getRepository(Users).nativeUpdate({ email }, { ...patch }); + } + + /** + * DMR6 — `INSERT INTO app_settings (key, value) VALUES (?, ?) ON + * CONFLICT(key) DO UPDATE SET value = excluded.value`, looped over the + * rows DMR2 read (the post-reopen instance-API-key restore). Generic + * inherited `upsert` (the SAME statement `AppSettingsRepository#setValue` + * issues) rather than that method directly — `value` may be SQL NULL here + * (preserved by DMR2 above), and `setValue`'s own signature types `value` + * as a non-nullable `string` for its own callers. + */ + async restoreInstanceApiKeys(rows: readonly DemoInstanceKeyRow[]): Promise { + this.validateRequestContext(); + const repo = this.em.getRepository(AppSettings); + for (const row of rows) { + await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + } +} diff --git a/server/src/db/repositories/DocumentConnections.repository.ts b/server/src/db/repositories/DocumentConnections.repository.ts new file mode 100644 index 000000000..1df1b8333 --- /dev/null +++ b/server/src/db/repositories/DocumentConnections.repository.ts @@ -0,0 +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'; + +/** + * 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 new file mode 100644 index 000000000..307275dde --- /dev/null +++ b/server/src/db/repositories/DocumentProviderFields.repository.ts @@ -0,0 +1,57 @@ +import type { DocumentProviderFields } from '../entities/DocumentProviderFields.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** 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.- ?)` + * with `value` already bound inside the fragment (`sql-functions.ts`'s own + * docstring) — used as a filter KEY paired with `{ $lte: tolerance }`, so + * the emitted SQL and bind order match the legacy statement exactly: + * `ABS(lat - ?) <= ?` then `ABS(lng - ?) <= ?`, the SAME `tolerance` value + * on both. + */ + async findDuplicateByCoords(trip_id: string, lat: number, lng: number, tolerance: number): Promise<{ id: number; google_ftid: string | null } | undefined> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('p') + .select(['p.id', 'p.google_ftid']) + .where('p.trip_id = ?', [trip_id]) + .andWhere({ + lat: { $ne: null }, + lng: { $ne: null }, + [absDifference(platform, 'p.lat', lat)]: { $lte: tolerance }, + [absDifference(platform, 'p.lng', lng)]: { $lte: tolerance }, + }) + .orderBy({ 'p.id': 'asc' }) + .limit(1) + .execute<{ id: number; google_ftid: string | null } | undefined>('get', false); + } + + /** + * PL29 (`exportGpx`'s waypoint read) — `SELECT p.name, p.description, + * p.address, p.lat, p.lng, p.route_geometry, c.name AS category FROM + * places p LEFT JOIN categories c ON c.id = p.category_id WHERE p.trip_id + * = ? ORDER BY p.id`. PL30 (the `days`/`day_assignments` itinerary half) + * is `DayAssignmentsRepository.listItineraryForGpx` — a different table + * root, kept there per the task-4 brief's own pointer. + */ + async listForGpx(trip_id: string): Promise<{ + name: string; description: string | null; address: string | null; + lat: number | null; lng: number | null; route_geometry: string | null; + category: string | null; + }[]> { + return this.qb('p') + .leftJoin('p.category', 'c') + .select(['p.name', 'p.description', 'p.address', 'p.lat', 'p.lng', 'p.route_geometry', 'c.name as category']) + .where('p.trip_id = ?', [trip_id]) + .orderBy({ 'p.id': 'asc' }) + .execute<{ name: string; description: string | null; address: string | null; lat: number | null; lng: number | null; route_geometry: string | null; category: string | null }[]>('all', false); + } + + /** + * PL24 (`PlacesService.buildDedupSet`'s read) — `SELECT name, lat, lng, + * google_place_id, google_ftid, osm_id, amap_poi_id FROM places WHERE + * trip_id = ?`, the exact projection the legacy statement named. Task 4's + * ruling left this raw under `PlacesService` because the dedup semantics + * (names lowercased+trimmed in JS, coordinates collected only for unnamed + * rows, provider ids collected for every row) are a JS decision, not a SQL + * one — Task 5 review L6 pointed out that leaving the SQL itself raw + * still contradicted the deliverable's "PL15/16/18/22 + PI2 are the only + * allowed survivors" grep. This method is the additive repository seam + * that resolves that: it carries only the projection, none of the JS + * folding logic, which stays in `PlacesService.buildDedupSet` exactly as + * it was. + */ + async listDedupInputs(trip_id: string): Promise<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null; + }[]> { + return this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id']) + .where('p.trip_id = ?', [trip_id]) + .execute<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null; + }[]>('all', false); + } + + /** + * PI1 (`place-image.ts:27`) — `SELECT 1 FROM places WHERE image_url = ? + * LIMIT 1`. Trip-agnostic on purpose (an uploaded image is ref-counted + * across every trip, not just the one it was uploaded from — a place + * copied or shared across trips can share the file). + */ + async existsByImageUrl(imageUrl: string): Promise { + const row = await this.qb('p') + .select(['p.id']) + .where({ image_url: imageUrl }) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * PL3 (`PlacesService.list`) — the trip's places, joined with their + * category's flat columns, filtered by up to four optional fragments + * built as conditional `andWhere`s rather than a hand-assembled SQL + * string (§18.3's surprise: the legacy statement's own text is not + * knowable from the source without tracing four `if`s — this makes each + * fragment a single, separately readable `andWhere` call instead). + * + * `filters.searchPattern` arrives PRE-BUILT (`%${escapeLikePattern(term)}%`, + * the service's job, matching every other "coercions stay in the service" + * split in this program) — this binds it verbatim into all three `LIKE … + * ESCAPE '\'` fragments, matching the legacy's one-param-three-times bind. + * The `ESCAPE '\'` clause is written into the raw fragment text because + * MikroORM's `$like` operator does not add one (§18.3). + * + * `filters.category`/`filters.tag` bind their raw string values with no + * `Number()` conversion (matching the legacy statement's own untyped + * bind); the two `assignment` fragments reference `place_tags`/ + * `day_assignments`/`days` by physical table name in a subquery (neither + * has, or needs, an entity-relation path for this shape) — the same + * raw-SQL-subquery shape `PlacesService.list`'s own legacy string used. + * + * `DISTINCT` kept via `.select([...], true)` even though a place has at + * most one category row (so it changes nothing about THIS join) — parity + * with the legacy statement's own `SELECT DISTINCT`, not a functional + * requirement. + */ + async listForTrip(trip_id: string, filters: { + searchPattern?: string; + category?: string; + tag?: string; + assignment?: 'all' | 'unassigned' | 'assigned'; + }): Promise { + const qb = this.qb('p') + .leftJoin('p.category', 'c') + // #2537: the atlas' cached region rides along (`LEFT JOIN place_regions pr ON pr.place_id = p.id`). + .leftJoin('p.place_regions', 'pr') + .select([ + 'p.*', 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + 'pr.country_code as country_code', 'pr.region_name as region_name', + ], true) + .where('p.trip_id = ?', [trip_id]); + + if (filters.searchPattern) { + qb.andWhere( + "(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\')", + [filters.searchPattern, filters.searchPattern, filters.searchPattern], + ); + } + if (filters.category) { + qb.andWhere('p.category_id = ?', [filters.category]); + } + if (filters.tag) { + qb.andWhere('p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)', [filters.tag]); + } + if (filters.assignment === 'unassigned') { + qb.andWhere( + 'p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)', + [trip_id], + ); + } else if (filters.assignment === 'assigned') { + qb.andWhere( + 'p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)', + [trip_id], + ); + } + + qb.orderBy({ 'p.created_at': 'desc' }); + + return qb.execute('all', false); + } + + /** + * PL53 (`PlacesService.setImageFromFile`, #1242) — `SELECT filename, + * original_name, mime_type, file_size FROM trip_files WHERE id = ? AND + * trip_id = ? AND deleted_at IS NULL`: the attachment a place takes its + * picture from, trash excluded. + */ + async findActiveTripFile(id: number, trip_id: number): Promise<{ filename: string; original_name: string; mime_type: string | null; file_size: number | null } | undefined> { + return await this.kysely() + .selectFrom('trip_files') + .select(['filename', 'original_name', 'mime_type', 'file_size']) + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 5 (`PlacesService` import paths) — every method below is + // additive: `insertPlace`/`updatePlace`/`findWithTagsAndRatings`/the + // `findDuplicateBy*` trio above are Task 4's, unchanged and reused verbatim + // by the four importers (GPX/KML/Google/Naver) this task converts. + // --------------------------------------------------------------------------- + + /** + * PL36 (`colorizeImportedTracks`'s read half) — `SELECT DISTINCT + * route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT + * NULL`. `DISTINCT` via `.select([...], true)` (the same shape + * `listForTrip`/PL3 uses for its own `SELECT DISTINCT`), not `$ne: null` + * folded into a single-object filter, so the emitted `WHERE` clause reads + * in the legacy's own `trip_id = ? AND route_color IS NOT NULL` order. + * + * Called and its result acted on inside the SAME `uow.transactional(...)` + * block `setRouteColor` below writes in (the service's own PL36+PL37 + * pairing) — read and write share one transaction so two concurrent + * imports can never both observe the same set of free colours. + */ + async distinctRouteColors(trip_id: string | number): Promise { + const rows = await this.qb('p') + .select(['p.route_color as c'], true) + .where('p.trip_id = ? AND p.route_color IS NOT NULL', [trip_id]) + .execute<{ c: string }[]>('all', false); + return rows.map((r) => r.c); + } + + /** + * PL37 (`colorizeImportedTracks`'s write half) — `UPDATE places SET + * route_color = ? WHERE id = ?`. No `updated_at` stamp — the legacy + * statement never wrote one for this column (#776's own deliberate + * omission, restated in `PlacesService`'s class docstring), and this + * method reproduces exactly that, not the "every write stamps + * `updated_at`" shape `updatePlace`/`backfillFtid`/`fillIfEmpty` below + * follow. + */ + async setRouteColor(id: number, route_color: string): Promise { + await this.nativeUpdate({ id }, { route_color }); + } + + /** + * PL39 (`storeGooglePlaces`'s dedup-backfill half) — `UPDATE places SET + * google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, unscoped + * by `trip_id` (matching the legacy statement exactly — the candidate id + * was already resolved against this trip by the `findDuplicatePlace` call + * immediately before it). + */ + async backfillFtid(id: number, google_ftid: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { google_ftid, updated_at: currentTimestamp(platform) }); + } + + /** + * PL43/PL44/PL46 (`enrichOne`'s two `UPDATE`s and + * `backfillMissingAddresses`'s one) — three legacy statements, one shape: + * `UPDATE places SET = COALESCE(, ?), ..., updated_at = + * CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. One `nativeUpdate` per + * CALL, built from whichever of the six columns the caller passes + * (`fields`'s keys are all optional — a key left OFF `fields` entirely is + * not touched at all, not even COALESCE'd against itself); every key + * that IS present renders `COALESCE(, ?)` through `coalesceParam` + * (never an inline `raw()`), so a column already holding a value is never + * overwritten and an empty one is filled from `fields`'s value. + * `updated_at` is stamped unconditionally — all three legacy statements + * stamp it, none of them conditionally. + * + * The three call shapes this serves (Task 5's "×3 shapes"): + * - PL43 (`enrichOne`'s main update) — `google_place_id`, `google_ftid`, + * `address`, `website`, `phone` all at once. + * - PL44 (`enrichOne`'s photo update) — `image_url` alone. + * - PL46 (`backfillMissingAddresses`) — `address` alone. + */ + async fillIfEmpty(id: number, trip_id: number, fields: { + google_place_id?: string | null; + google_ftid?: string | null; + address?: string | null; + website?: string | null; + phone?: string | null; + image_url?: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + const data: { + google_place_id?: ReturnType; + google_ftid?: ReturnType; + address?: ReturnType; + website?: ReturnType; + phone?: ReturnType; + image_url?: ReturnType; + updated_at: ReturnType; + } = { updated_at: currentTimestamp(platform) }; + if (fields.google_place_id !== undefined) data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id); + if (fields.google_ftid !== undefined) data.google_ftid = coalesceParam(platform, 'google_ftid', fields.google_ftid); + if (fields.address !== undefined) data.address = coalesceParam(platform, 'address', fields.address); + if (fields.website !== undefined) data.website = coalesceParam(platform, 'website', fields.website); + if (fields.phone !== undefined) data.phone = coalesceParam(platform, 'phone', fields.phone); + if (fields.image_url !== undefined) data.image_url = coalesceParam(platform, 'image_url', fields.image_url); + await this.nativeUpdate({ id, trip: trip_id }, data); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`trips.rpc.ts`) — additive (per `task-4-report.md`'s own + // note that this method "may not exist"): every column above is Task 4/5's, + // unchanged. + // --------------------------------------------------------------------------- + + /** + * RP2 (`trips.rpc.ts::getPlaces`) — `SELECT * FROM places WHERE trip_id = ? + * ORDER BY created_at DESC`. The trip's place POOL has no itinerary + * position of its own (`day_id`/`order_index` live on `day_assignments`), + * so this orders by `created_at` like the REST list does — the RPC + * method's own comment (`trips.rpc.ts`). + */ + async listForTripOrdered(trip_id: number): Promise { + return await this.qb('p') + .select(['p.*']) + .where({ trip: trip_id }) + .orderBy({ 'p.created_at': 'desc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 6 (`share.service.ts`) — additive. + // --------------------------------------------------------------------------- + + /** + * `share.service.ts:340` SH12 (`getSharedTripData`'s share_map place-pool + * read) — **SECURITY-CRITICAL (#2320)**: a named 20-column allow-list, + * never `p.*`. Withheld on purpose (matching `PUBLIC_PLACE_COLUMNS`'s own + * doc comment in `share.service.ts`): the owner's booking-status columns + * (`reservation_status`/`reservation_notes`/`reservation_datetime`), every + * routing/dedup id (`google_place_id`, `google_ftid`, `osm_id`, + * `amap_poi_id`), `route_geometry`/`route_color`/`stop_type`/ + * `fill_percent`, and `source`. Kysely, not `qb()`+`columnRef`: this read + * joins `p.category` under alias `c`, and both `trip_id`/`category_id` are + * `persist(false)` mirror columns — the exact trap + * `DayAssignmentsRepository.assignmentWithPlaceSelect`'s docstring + * documents. `ORDER BY p.created_at DESC`, matching the legacy statement. + */ + async listPublicForShare(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('places as p') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select([ + 'p.id', 'p.trip_id', 'p.name', 'p.description', 'p.lat', 'p.lng', 'p.address', 'p.category_id', + 'p.price', 'p.currency', 'p.place_time', 'p.end_time', 'p.duration_minutes', 'p.notes', + 'p.image_url', 'p.website', 'p.phone', 'p.transport_mode', 'p.created_at', 'p.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('p.trip_id', '=', trip_id as number) + .orderBy('p.created_at', 'desc') + .execute(); + } + + /** + * `share.service.ts:461` SH18 (`getSharedPlacePhotoKey`) — + * **SECURITY-CRITICAL**: the anonymous photo-serving ownership gate. + * `SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?` — both must + * match, proving the requested proxy URL belongs to a place on the SAME + * trip the share token covers, never just any place anywhere with that + * `image_url`. + */ + async existsByTripAndImageUrl(trip_id: number | string, imageUrl: string): Promise { + const row = await this.kysely<{ places: { id: number; trip_id: number; image_url: string | null } }>() + .selectFrom('places') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('image_url', '=', imageUrl) + .limit(1) + .executeTakeFirst(); + return !!row; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP40 (`trips.service.ts::copy`'s places read) — `SELECT * FROM places + * WHERE trip_id = ?`, no `ORDER BY` at all (unlike RP2's `listForTripOrdered` + * above, which adds `ORDER BY created_at DESC` for the RPC list view). The + * legacy statement relies on SQLite's own rowid-ascending scan order for a + * plain, unindexed `WHERE trip_id = ?` — reproduced here the same way, by + * NOT adding an `ORDER BY`, so the copy's places are inserted (and so + * relatively ordered) the same way the legacy loop did. + */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.qb('p') + .select(['p.*']) + .where('p.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP41 (`trips.service.ts::copy`'s place INSERT) — the 28-column + * `INSERT INTO places (...)` the copy loop builds, three columns wider + * than PL4's `insertPlace` (`reservation_status`, `reservation_notes`, + * `reservation_datetime` — a copy carries the source place's reservation + * fields verbatim; `PlacesService.create` never sets them at creation + * time, which is why PL4's own column set omits them). Every value here + * is copied straight off the source row with no coercion of its own — the + * copy loop's job is to remap `trip_id`, nothing else. + */ + async insertPlaceCopy(input: { + 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; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + website: string | null; + phone: string | null; + transport_mode: string | null; + osm_id: string | null; + amap_poi_id: string | null; + route_geometry: string | null; + route_color: string | null; + stop_type: string | null; + fill_percent: number | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + name: input.name, + description: input.description, + lat: input.lat, + lng: input.lng, + address: input.address, + category: input.category_id, + price: input.price, + currency: input.currency, + reservation_status: input.reservation_status, + reservation_notes: input.reservation_notes, + reservation_datetime: input.reservation_datetime, + place_time: input.place_time, + end_time: input.end_time, + duration_minutes: input.duration_minutes, + notes: input.notes, + image_url: input.image_url, + google_place_id: input.google_place_id, + google_ftid: input.google_ftid, + website: input.website, + phone: input.phone, + transport_mode: input.transport_mode, + osm_id: input.osm_id, + amap_poi_id: input.amap_poi_id, + route_geometry: input.route_geometry, + route_color: input.route_color, + stop_type: input.stop_type, + fill_percent: input.fill_percent, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripService`/`ChargingService`) — additive, per this + // task's own file-ownership rule ("all additive methods on Days/Places/ + // DayAssignments repositories"). + // --------------------------------------------------------------------------- + + /** + * RT13 (`RoadtripService.trackExists`) — `SELECT id FROM places WHERE id = + * ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != + * ''`. Two `.andWhere()` calls (rule 23 — typed conditions, not an inline + * SQL string) rather than one `$and` array: the two operators (`$ne: null` + * for `IS NOT NULL`, `$ne: ''` for `!= ''`) target the SAME property, so a + * single filter object literal cannot carry both keys. + */ + async isTrackInTrip(id: number, trip_id: number): Promise { + const row = await this.qb('p') + .select(['p.id']) + .where({ id, trip: trip_id }) + .andWhere({ route_geometry: { $ne: null } }) + .andWhere({ route_geometry: { $ne: '' } }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** CH1 (`ChargingService.read`) — `SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?`. */ + async findChargingProbe(id: number, trip_id: number): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> { + const row = await this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.stop_type']) + .where({ id, trip: trip_id }) + .execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>('get', false); + return row ?? undefined; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** AC9 (`AccommodationsService.stampLodging`) — `SELECT stop_type FROM places WHERE id = ?`. */ + async getStopType(id: number): Promise<{ stop_type: string | null } | undefined> { + return await this.qb('p') + .select(['p.stop_type']) + .where({ id }) + .execute<{ stop_type: string | null } | undefined>('get', false); + } + + /** AC10 (`AccommodationsService.stampLodging`) — `UPDATE places SET stop_type = 'hotel' WHERE id = ?`. Does NOT stamp `places.updated_at` (matches the legacy statement, R7 class — flagged, not fixed). */ + async stampHotel(id: number): Promise { + await this.nativeUpdate({ id }, { stop_type: 'hotel' }); + } + + /** AC31 (`AccommodationsService.createAccommodation`) — `SELECT name FROM places WHERE id = ?`, the auto-created partner booking's title source. */ + async getName(id: number): Promise<{ name: string } | undefined> { + return await this.qb('p') + .select(['p.name']) + .where({ id }) + .execute<{ name: string } | undefined>('get', false); + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL2 (`FilesService.findForeignLinkTarget`'s place branch) — `SELECT 1 + * FROM places WHERE id = ? AND trip_id = ?`, re-expressed as "what trip + * does this row belong to" (R12, the same shape as + * `ReservationsRepository.findTripId`'s docstring). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('p') + .select([columnRef(platform, 'p.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * BG20 (`BudgetService.rebaseTripCurrency`'s `pinPlaces`) — `UPDATE places + * SET currency = ?, updated_at = CURRENT_TIMESTAMP WHERE trip_id = ? AND + * price IS NOT NULL AND (currency IS NULL OR currency = '')`. Only priced + * places are denominated; `updated_at` doubles as the optimistic- + * concurrency token (#1135). + */ + async pinCurrencyForTrip(trip_id: number | string, prev_currency: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { trip: trip_id as number, price: { $ne: null }, $or: [{ currency: null }, { currency: '' }] }, + { currency: prev_currency, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive, append-only per that task's + // own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * AT2 (`AtlasService#getPlacesForTrips`) — `SELECT * FROM places WHERE + * trip_id IN (...)`. Dynamic `IN`, empty-array short-circuit (the + * `listAllForTrip`/single-id precedent above, widened to a batch of trip + * ids — every atlas read that resolves a user's trips first passes the + * whole id list through here in one call rather than one query per trip). + */ + async listForTripIds(trip_ids: number[]): Promise { + if (trip_ids.length === 0) return []; + return await this.qb('p') + .select(['p.*']) + .where({ trip: { $in: trip_ids } }) + .execute('all', false); + } + + /** + * AT41 (`AtlasService#getTravelStats`) — `SELECT DISTINCT p.address, + * p.lat, p.lng, pr.region_name FROM places p JOIN trips t ON p.trip_id = + * t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id LEFT JOIN + * place_regions pr ON pr.place_id = p.id WHERE t.user_id = ? OR tm.user_id + * = ?`. The resolved region rides along so `cityFromAddress` can tell the + * city apart from the region sitting right above it in the same address + * (#1115) — unchanged from the legacy projection. + */ + async listAddressesForUser(user_id: number): Promise<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }[]> { + return await this.kysely() + .selectFrom('places as p') + .innerJoin('trips as t', 't.id', 'p.trip_id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .leftJoin('place_regions as pr', 'pr.place_id', 'p.id') + .select(['p.address', 'p.lat', 'p.lng', 'pr.region_name']) + .distinct() + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 3 (`DawarichSuggestionsService`) — additive. + // --------------------------------------------------------------------------- + + /** + * DWS7 (`dawarich-suggestions.service.ts::acceptAsPlace`) — `UPDATE places + * SET source = 'dawarich' WHERE id = ?`. No `updated_at` stamp — the + * legacy statement never wrote one for this column (same "no stamp" + * shape {@link setRouteColor} above already documents for a different + * column). Cross-domain write from the Dawarich integration (3h) into + * this 3c-owned table. + */ + async setSource(id: number, source: string): Promise { + await this.nativeUpdate({ id }, { source }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 2 (`CollectionsService`) — additive, cross-domain. Flagged in + // the task's own report per its brief: these touch `places`, a 3c-owned + // table, from the collections cluster's own copy/import surface. + // --------------------------------------------------------------------------- + + /** + * CL45 (`importablePlaces`) — the trip's places as offered to the + * collection import dialog, one row per place with two correlated scalar + * subqueries resolving the EARLIEST day it is assigned to (a place can + * sit on several days): `SELECT p.id AS place_id, p.name, p.address, + * p.lat, p.lng, p.category_id, p.image_url, p.google_place_id, + * p.google_ftid, p.osm_id, (SELECT MIN(d.day_number) FROM day_assignments + * da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND + * d.trip_id=p.trip_id) AS day_number, (SELECT d.date FROM day_assignments + * da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND + * d.trip_id=p.trip_id ORDER BY d.day_number ASC LIMIT 1) AS date FROM + * places p WHERE p.trip_id=? ORDER BY p.name COLLATE NOCASE`. Kysely — a + * correlated scalar subquery has no MikroORM QueryBuilder expression + * (`JourneysRepository.listForUser`'s own `eb.selectFrom(...).whereRef + * (...).as(...)` precedent, the closest analog: five correlated + * subqueries over a `LEFT JOIN`). `ORDER BY ... COLLATE NOCASE` is + * Kysely's own native `OrderByItemBuilder.collate('nocase')` — a portable + * Kysely API, not a raw SQLite fragment, so it needs no + * `sql-functions.ts` dialect wrapper (rule 5 covers dialect FUNCTIONS, + * not Kysely's own builder surface). + */ + async listImportable(tripId: number): Promise { + return await this.kysely() + .selectFrom('places as p') + .select((eb) => [ + 'p.id as place_id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.category_id', 'p.image_url', + 'p.google_place_id', 'p.google_ftid', 'p.osm_id', + eb + .selectFrom('day_assignments as da') + .innerJoin('days as d', 'd.id', 'da.day_id') + .select((eb2) => eb2.fn.min('d.day_number').as('m')) + .whereRef('da.place_id', '=', 'p.id') + .whereRef('d.trip_id', '=', 'p.trip_id') + .as('day_number'), + eb + .selectFrom('day_assignments as da2') + .innerJoin('days as d2', 'd2.id', 'da2.day_id') + .select('d2.date') + .whereRef('da2.place_id', '=', 'p.id') + .whereRef('d2.trip_id', '=', 'p.trip_id') + .orderBy('d2.day_number', 'asc') + .limit(1) + .as('date'), + ]) + .where('p.trip_id', '=', tripId) + .orderBy('p.name', (ob) => ob.collate('nocase').asc()) + .execute(); + } + + /** + * CL57 (`setStatusFromTrip`) — `SELECT id, name, lat, lng, + * google_place_id, google_ftid, osm_id FROM places WHERE trip_id=? AND id + * IN (...)`. `$in` for the dynamic list (rule 17b), the same + * `scopedIds`-shaped raw `trip_id` condition above. + */ + async listByTripAndIds(trip_id: number, ids: number[]): Promise { + if (ids.length === 0) return []; + return await this.qb('p') + .select(['p.id', 'p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id']) + .where({ 'p.trip_id': trip_id }) + .andWhere({ id: { $in: ids } }) + .execute('all', false); + } + + /** + * CL63 (`copyToTrip`'s trip dedup set) — `SELECT name, lat, lng, + * google_place_id, google_ftid, osm_id FROM places WHERE trip_id=?`. + */ + async dedupCandidatesForTrip(trip_id: number): Promise[]> { + return await this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id']) + .where({ trip: trip_id }) + .execute[]>('all', false); + } + + /** + * CL64 (`copyToTrip`, PREPARED/looped, TX) — `INSERT INTO places (trip_id, + * name, description, lat, lng, address, category_id, price, currency, + * notes, image_url, google_place_id, google_ftid, website, phone, + * osm_id) VALUES (...)` — a genuinely NARROWER 16-column insert than + * {@link insertPlace}'s full 27-column shape (no `place_time`/`end_time`/ + * `duration_minutes`/`transport_mode`/`route_geometry`/`route_color`/ + * `stop_type`/`fill_percent`/`amap_poi_id`): the legacy statement leaves + * those to the table's own `DEFAULT` clauses + * (`Migration20200101000000_baseline_schema.ts`: `duration_minutes + * INTEGER DEFAULT 60`, `transport_mode TEXT DEFAULT 'walking'`, + * `reservation_status TEXT DEFAULT 'none'`), unlike `insertPlace`'s own + * caller (`PlacesService.create`), which always resolves and writes every + * one of those columns itself. Kysely (a narrower insert-only interface, + * `CollectionsRepository`'s own `CollectionPlacesWriteKyselyDB` + * precedent for the identical "narrower than the full entity" shape) so + * this genuinely different column set never widens `insertPlace`'s own + * required-field list. Returns the generated id. + */ + async insertFromCollectionPlace(row: { + trip_id: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + website: string | null; + phone: string | null; + osm_id: string | null; + }): Promise { + const result = await this.kysely().insertInto('places').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (`maps.service.ts`'s photo-fetch method, MAP9) — additive, + // per this task's own file-ownership rule ("additive methods on + // Reservations/ReservationEndpoints/Days/Places/Users repositories"). + // --------------------------------------------------------------------------- + + /** + * MAP9 — `UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP + * WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')`. + * **SECURITY/PARITY**: the `$or` guard is what stops this from ever + * clobbering a place's already-set/custom image — preserved exactly. + * Unscoped by `trip_id`/`id` (matching the legacy statement): a + * `google_place_id` can appear on more than one place row across + * different trips, and every empty-image row sharing it is filled in one + * statement. + */ + async setImageUrlIfUnset(google_place_id: string, image_url: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeUpdate( + { google_place_id, $or: [{ image_url: null }, { image_url: '' }] }, + { image_url, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::placesByDay`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT da.day_id, p.name, p.address, p.lat, p.lng, p.place_time, + * p.end_time, p.duration_minutes, p.notes, p.transport_mode, c.name AS + * category FROM day_assignments da JOIN places p ON p.id = da.place_id + * LEFT JOIN categories c ON c.id = p.category_id WHERE p.trip_id = ? AND + * da.accommodation_id IS NULL ORDER BY da.day_id ASC, da.order_index ASC`. + * Kysely — the SAME cross-table-read escape hatch `ReservationsRepository + * .listUnplannedPlacesForPublicApi`'s precedent (3d Task 4) uses for this + * exact `public-api.service.ts` surface; landed on `PlacesRepository` + * rather than grouped there because the projection is place-shaped, per + * this domain plan's own ruling. + */ + async listAssignedForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select([ + 'da.day_id', + 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time', + 'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category', + ]) + .where('p.trip_id', '=', trip_id) + .where('da.accommodation_id', 'is', null) + .orderBy('da.day_id', 'asc') + .orderBy('da.order_index', 'asc') + .execute(); + return rows as PublicApiAssignedPlaceRow[]; + } +} + +/** The `places` columns {@link PlacesRepository.listAssignedForPublicApi} selects, typed off {@link PlaceRow}. */ +type PublicApiPlaceColumns = Pick< + PlaceRow, + 'name' | 'address' | 'lat' | 'lng' | 'place_time' | 'end_time' | 'duration_minutes' | 'notes' | 'transport_mode' +>; + +/** {@link PlacesRepository.listAssignedForPublicApi}'s projection. */ +export interface PublicApiAssignedPlaceRow extends PublicApiPlaceColumns { + day_id: number; + category: string | null; +} + +/** {@link PlacesRepository.listAssignedForPublicApi}'s narrow `day_assignments`/`places`/`categories` shape. */ +interface PublicApiAssignedPlacesKyselyDB { + day_assignments: { day_id: number; place_id: number; accommodation_id: number | null; order_index: number | null }; + places: PublicApiPlaceColumns & Pick; + categories: { id: number; name: string }; +} + +/** {@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. */ +interface PlacesAddressesForUserKyselyDB { + places: { id: number; trip_id: number; address: string | null; lat: number | null; lng: number | null }; + trips: { id: number; user_id: number }; + trip_members: { trip_id: number; user_id: number }; + place_regions: { place_id: number; region_name: string | null }; +} diff --git a/server/src/db/repositories/PluginActions.repository.ts b/server/src/db/repositories/PluginActions.repository.ts new file mode 100644 index 000000000..226bc9b6d --- /dev/null +++ b/server/src/db/repositories/PluginActions.repository.ts @@ -0,0 +1,58 @@ +import type { PluginActions } from '../entities/PluginActions.entity'; +import type { PluginActionScope } from '@trek/shared'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `actionsOf`'s own projection — mapped to `PluginActionDescriptor` in the service. */ +export interface PluginActionRow { + action_key: string; + label: string; + hint: string | null; + danger: number; + scope: PluginActionScope; +} + +export class PluginActionsRepository extends TrekRepository { + /** PS3 (`plugins.service.ts#instanceActionsCount`) — `SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */ + async countForPluginScope(pluginId: string, scope: PluginActionScope): Promise { + return await this.count({ plugin_id: pluginId, scope }); + } + + /** `actionsOf` — `SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order`. Caller wraps this in its own try/catch → []. */ + async listForPluginScope(pluginId: string, scope: PluginActionScope): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope }, + { fields: ['action_key', 'label', 'hint', 'danger', 'scope'], orderBy: { sort_order: 'asc' } }, + ); + return rows.map((r) => ({ action_key: r.action_key, label: r.label, hint: r.hint ?? null, danger: r.danger, scope: r.scope as PluginActionScope })); + } + + /** PR35 (uninstall cascade) — `DELETE FROM plugin_actions WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * DI6 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `INSERT INTO plugin_actions + * (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?,?,?,?,?,?,?)`, + * looped once per manifest action, paired with DI5's `deleteAllForPlugin` above (the + * same delete-then-reinsert re-declare sequence). `insertMany` over a per-row loop: + * one batched native insert, same rows the legacy per-row `.run()` loop produced. A + * no-op on an empty manifest `actions[]`. + */ + async insertActions(pluginId: string, actions: NewPluginActionRow[]): Promise { + if (!actions.length) return; + await this.insertMany( + actions.map((a) => ({ plugin_id: pluginId, action_key: a.action_key, label: a.label, hint: a.hint, danger: a.danger, scope: a.scope, sort_order: a.sort_order })), + ); + } +} + +/** DI6's own row shape — `discoverPlugins#upsert`'s per-manifest-action insert. */ +export interface NewPluginActionRow { + action_key: string; + label: string; + hint: string | null; + danger: number; + scope: PluginActionScope; + sort_order: number; +} diff --git a/server/src/db/repositories/PluginCapabilityAudit.repository.ts b/server/src/db/repositories/PluginCapabilityAudit.repository.ts new file mode 100644 index 000000000..fe3a5030d --- /dev/null +++ b/server/src/db/repositories/PluginCapabilityAudit.repository.ts @@ -0,0 +1,181 @@ +import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR45) — R-hash-chain's own read/append/prune surface + * (`host/plugin-audit.ts`) belongs to Task 3, which adds its own additive + * methods to this same repository. Do NOT confuse this table with the global, + * admin-facing `audit_log` (3i's `AuditLogRepository`) — two unrelated "audit" + * systems (plan3j-inputs.md corrections §4). + * + * Plan 3j Task 3 note: the methods below are the promised additive surface — + * `pruneKeepingNewest`/`lastHash`/`insertRow`/`forUser`/`forPlugin`/`budgetSeed`, + * backing `host/plugin-audit.ts` (R-hash-chain) and `host/plugin-host-state.ts`'s + * `budgetFor` seed read (HS1). PR45's `deleteAllForPlugin` above is untouched. + */ +export class PluginCapabilityAuditRepository extends TrekRepository { + /** PR45 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_capability_audit WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — R-hash-chain (host/plugin-audit.ts) + HS1 (plugin-host-state.ts) + // ----------------------------------------------------------------------- + + /** + * PA1 (`pruneAudit`'s amortized retention) — `` DELETE FROM plugin_capability_audit + * WHERE plugin_id = ? AND id NOT IN (SELECT id FROM plugin_capability_audit WHERE + * plugin_id = ? ORDER BY id DESC LIMIT ?) ``. Same `NOT IN (subquery … ORDER BY … + * LIMIT)` shape as `PluginErrorLogRepository#pruneKeepingRecent` — no `nativeDelete`/ + * `FilterQuery` equivalent (no correlated-subquery support), so this goes through + * `this.kysely()` (`_shared/trek-repository.ts`'s escape hatch). The `keep` count is + * a `?`-bound `limit()` call, never interpolated into SQL text. + */ + async pruneKeepingNewest(pluginId: string, keep: number): Promise { + const db = this.kysely(); + await db + .deleteFrom('plugin_capability_audit') + .where('plugin_id', '=', pluginId) + .where( + 'id', + 'not in', + db.selectFrom('plugin_capability_audit').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(keep), + ) + .execute(); + } + + /** + * PA2 (`appendAudit`'s chain read) — `SELECT hash FROM plugin_capability_audit + * WHERE plugin_id = ? ORDER BY id DESC LIMIT 1`. `null` means "no prior row for + * this plugin" (a fresh chain) — `appendAudit` folds that into `''` the same way + * the legacy `?.hash ?? ''` did. + */ + async lastHash(pluginId: string): Promise { + const row = await this.findOne({ plugin_id: pluginId }, { fields: ['hash'], orderBy: { id: 'desc' } }); + return row?.hash ?? null; + } + + /** + * PA3 (`appendAudit`'s own write) — `INSERT INTO plugin_capability_audit + * (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) + * VALUES (?,?,?,?,?,?,?,?)`. The hash itself (and `ts`, computed in JS as + * `new Date().toISOString()` BEFORE the hash) is computed by the caller — + * `appendAudit` in `host/plugin-audit.ts` — never here: this method persists + * the already-hashed row byte-for-byte, so R-hash-chain's construction stays + * visible and testable in one place. + */ + async insertRow(entry: NewAuditRow): Promise { + await this.insert({ + plugin_id: entry.plugin_id, + acting_user_id: entry.acting_user_id, + method: entry.method, + resource: entry.resource, + code: entry.code, + ts: entry.ts, + prev_hash: entry.prev_hash, + hash: entry.hash, + }); + } + + /** + * PA4 (`readAuditForUser`) — `SELECT a.ts, a.plugin_id, p.name AS plugin_name, + * a.method, a.resource, a.code FROM plugin_capability_audit a LEFT JOIN plugins + * p ON p.id = a.plugin_id WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`. + * Kysely against the literal `plugins` table, the same `AuditLogRepository + * #listPage` LEFT-JOIN precedent (a deleted/never-existing plugin leaves + * `plugin_name` null, the row itself still returned) — no `PluginsRepository` + * dependency: the join only needs the table, not that repository's own methods. + */ + async forUser(userId: number, limit: number): Promise { + const rows = await this.kysely() + .selectFrom('plugin_capability_audit as a') + .leftJoin('plugins as p', 'p.id', 'a.plugin_id') + .select(['a.ts', 'a.plugin_id', 'p.name as plugin_name', 'a.method', 'a.resource', 'a.code']) + .where('a.acting_user_id', '=', userId) + .orderBy('a.id', 'desc') + .limit(limit) + .execute(); + return rows.map((r) => ({ ts: r.ts, plugin_id: r.plugin_id, plugin_name: r.plugin_name ?? null, method: r.method, resource: r.resource, code: r.code })); + } + + /** + * PA5 (`readAudit`, admin per-plugin view) — `SELECT ts, acting_user_id, method, + * resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id + * DESC LIMIT ?`. + */ + async forPlugin(pluginId: string, limit: number): Promise { + const rows = await this.find( + { plugin_id: pluginId }, + { fields: ['ts', 'acting_user_id', 'method', 'resource', 'code'], orderBy: { id: 'desc' }, limit }, + ); + return rows.map((r) => ({ ts: r.ts, acting_user_id: r.acting_user_id ?? null, method: r.method, resource: r.resource ?? null, code: r.code })); + } + + /** + * HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — `SELECT method, COUNT(*) + * AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND + * ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY + * method`. A GROUP BY aggregate — Kysely, same escape-hatch rule as PA1/PA4. + */ + async budgetSeed(pluginId: string, since: string): Promise> { + const rows = await this.kysely() + .selectFrom('plugin_capability_audit') + .select(({ fn }) => ['method', fn.count('id').as('n')]) + .where('plugin_id', '=', pluginId) + .where('code', '=', 'ok') + .where('ts', '>=', since) + .where('method', 'in', ['ai.complete', 'ai.extract', 'notify.send']) + .groupBy('method') + .execute(); + return rows.map((r) => ({ method: r.method, n: Number(r.n) })); + } +} + +/** The column set `insertRow` writes — everything `appendAudit` has already computed. */ +export interface NewAuditRow { + plugin_id: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; + ts: string; + prev_hash: string | null; + hash: string; +} + +/** `forUser`'s own projection (PA4). */ +export interface AuditForUserRow { + ts: string; + plugin_id: string; + plugin_name: string | null; + method: string; + resource: string | null; + code: string; +} + +/** `forPlugin`'s own projection (PA5). */ +export interface AuditForPluginRow { + ts: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; +} + +/** Kysely shape for `pruneKeepingNewest`/`forUser`/`budgetSeed`'s escape-hatch queries — only the columns/tables they actually touch. */ +interface PluginCapabilityAuditKyselyDB { + plugin_capability_audit: { + id: number; + plugin_id: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; + ts: string; + prev_hash: string | null; + hash: string; + }; + plugins: { id: string; name: string }; +} diff --git a/server/src/db/repositories/PluginEgressHosts.repository.ts b/server/src/db/repositories/PluginEgressHosts.repository.ts new file mode 100644 index 000000000..582ae9ce0 --- /dev/null +++ b/server/src/db/repositories/PluginEgressHosts.repository.ts @@ -0,0 +1,39 @@ +import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class PluginEgressHostsRepository extends TrekRepository { + /** PR20 — `SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host` (`operatorEgressHosts`). Caller wraps this in its own try/catch — a slimmed test schema without this table never blocks activation. */ + async listHostsForPlugin(pluginId: string): Promise { + const rows = await this.find({ plugin_id: pluginId }, { fields: ['host'], orderBy: { host: 'asc' } }); + return rows.map((r) => r.host); + } + + /** PS1 — `SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?` (`plugins.service.ts#egressHostCount`). Caller wraps this in its own try/catch → 0. */ + async countForPlugin(pluginId: string): Promise { + return await this.count({ plugin_id: pluginId }); + } + + /** + * PR22/PR23 — `setOperatorEgressHosts`'s DELETE+loop-INSERT: `DELETE FROM + * plugin_egress_hosts WHERE plugin_id = ?` then, per host, `INSERT OR IGNORE INTO + * plugin_egress_hosts (plugin_id, host) VALUES (?, ?)`. + * + * **R-uninstall's ONE named transaction** (the plan's "For the user" line): the CALLER + * (`PluginRuntimeService.setOperatorEgressHosts`) wraps this call in `uow.transactional` + * — this method issues the same two-step DELETE-then-INSERT-loop shape the legacy code + * did, but now atomically, so a crash mid-write leaves the OLD host set intact instead + * of a plugin with zero egress hosts. `INSERT OR IGNORE` -> `onConflictAction: 'ignore'` + * against the `(plugin_id, host)` unique pair, unchanged from the legacy statement. + */ + async replaceAllForPlugin(pluginId: string, hosts: string[]): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + for (const host of hosts) { + await this.upsert({ plugin_id: pluginId, host }, { onConflictFields: ['plugin_id', 'host'], onConflictAction: 'ignore' }); + } + } + + /** PR36 (uninstall cascade) — `DELETE FROM plugin_egress_hosts WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginEntityMetadata.repository.ts b/server/src/db/repositories/PluginEntityMetadata.repository.ts new file mode 100644 index 000000000..bdfc9aa0d --- /dev/null +++ b/server/src/db/repositories/PluginEntityMetadata.repository.ts @@ -0,0 +1,101 @@ +import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +export class PluginEntityMetadataRepository extends TrekRepository { + /** PR40 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_entity_metadata WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // MR1–MR6 (Plan 3j Task 5, `plugins/host/rpc/meta.rpc.ts`'s `meta.get`/ + // `meta.set`/`meta.list`/`meta.delete` RPC handlers — a plugin's own + // namespaced key/value store on a core entity). + // ----------------------------------------------------------------------- + + /** + * MR1/MR2 — `SELECT value FROM plugin_entity_metadata WHERE plugin_id=? + * AND entity_type=? AND entity_id=? AND key=?` — MR2 (`set`'s pre-write + * existence check) is byte-identical text, existence-only; the caller + * checks `!== null` rather than this repository exposing a second, + * narrower method for the same 4-column read. + */ + async findValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** MR3 — `SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?`, the `META_KEYS_MAX` per-entity quota. */ + async countForEntity(pluginId: string, entityType: string, entityId: number): Promise { + return await this.count({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId }); + } + + /** + * MR4 — `` INSERT INTO plugin_entity_metadata (plugin_id, entity_type, + * entity_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now')) + * ON CONFLICT(plugin_id, entity_type, entity_id, key) DO UPDATE SET value + * = excluded.value, updated_at = excluded.updated_at `` — the WIDEST + * composite-key upsert in the plan (4 key columns, the entity's own + * `uniques`). `PluginUserConfigRepository.upsertConfig`'s own precedent: + * `currentTimestamp(platform)` bound once so the inserted row and the + * `excluded.updated_at` merge target agree, matching the legacy + * single-statement `datetime('now')` call exactly. + */ + async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key, value, updated_at: currentTimestamp(platform) }, + { + onConflictFields: ['plugin_id', 'entity_type', 'entity_id', 'key'], + onConflictAction: 'merge', + onConflictMergeFields: ['value', 'updated_at'], + }, + ); + } + + /** MR5 — `SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key`. */ + async listForEntity(pluginId: string, entityType: string, entityId: number): Promise<{ key: string; value: string }[]> { + const rows = await this.find( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId }, + { fields: ['key', 'value'], orderBy: { key: 'asc' } }, + ); + return rows.map((r) => ({ key: r.key, value: r.value ?? '' })); + } + + /** MR6 — `DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?`, returning whether a row existed (`.changes > 0`). */ + async deleteValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }); + return changed > 0; + } + + /** + * Plan 3j Task 7 fix wave, must-land 2 (task-7-review.md): MR2 (the + * pre-write existence check), MR3 (the `META_KEYS_MAX` count gate) and MR4 + * (the upsert) made atomic. `meta.rpc.ts#set` used to run these as three + * separately-awaited calls — two concurrent `meta.set` calls for two + * DIFFERENT new keys on the same (plugin, entity) could both read + * `count < max` before either's write landed, so a 100-key cap let a live + * burst land 130 rows. Wrapped in `this.getEntityManager().transactional` + * — the same MikroORM API `UnitOfWork.transactional` itself calls (see + * that class's own docstring on why its default NESTED/savepoint + * propagation is safe even when a caller is already inside a transaction) + * — so `MetaRpc` needs no new constructor dependency: this fix wave found + * `tests/helpers/plugin-host.ts` (which hand-constructs `MetaRpc`) mid-edit + * by a concurrent Plan 4 task and could not touch its construction site, + * so the fix stays entirely inside the repository it already owns. + * Returns whether the write happened; `false` means a NEW key was blocked + * by the cap (the caller maps that to `BadParams`, same message as before). + */ + async upsertValueCapped(pluginId: string, entityType: string, entityId: number, key: string, value: string, maxKeys: number): Promise { + return this.getEntityManager().transactional(async () => { + const exists = (await this.findValue(pluginId, entityType, entityId, key)) !== null; + if (!exists) { + const n = await this.countForEntity(pluginId, entityType, entityId); + if (n >= maxKeys) return false; + } + await this.upsertValue(pluginId, entityType, entityId, key, value); + return true; + }); + } +} diff --git a/server/src/db/repositories/PluginErrorLog.repository.ts b/server/src/db/repositories/PluginErrorLog.repository.ts new file mode 100644 index 000000000..164210bd1 --- /dev/null +++ b/server/src/db/repositories/PluginErrorLog.repository.ts @@ -0,0 +1,57 @@ +import type { PluginErrorLog } from '../entities/PluginErrorLog.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** PR2 delivery row / plugins.service.ts `errors()`'s own projection. */ +export interface PluginErrorLogRow { + ts: string; + level: string; + message: string; +} + +/** `pruneKeepingRecent`'s own `this.kysely()` escape hatch (`_shared/trek-repository.ts`) — the `id`/`plugin_id` columns its correlated subquery needs. */ +interface PluginErrorLogKyselyDB { + plugin_error_log: { id: number; plugin_id: string }; +} + +export class PluginErrorLogRepository extends TrekRepository { + /** PR2 — `INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)` (the supervisor's `onLog` callback — §4a, lazily resolved, own try/catch swallow unchanged in the caller). */ + async insertLog(pluginId: string, level: string, message: string): Promise { + await this.insert({ plugin_id: pluginId, level, message }); + } + + /** + * PR3 — `` DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN ( + * SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${retention} + * ) `` (`pruneErrorLog`'s retention trim). The `NOT IN (subquery … ORDER BY … LIMIT)` + * shape has no `nativeDelete`/`FilterQuery` equivalent (no correlated-subquery support), + * so this is one of the few statements per repository that goes through `this.kysely()` + * (`_shared/trek-repository.ts`'s escape hatch) rather than the QueryBuilder — the + * `retention` count is a `?`-bound `limit()` call, never interpolated into SQL text. + */ + async pruneKeepingRecent(pluginId: string, retention: number): Promise { + const db = this.kysely(); + await db + .deleteFrom('plugin_error_log') + .where('plugin_id', '=', pluginId) + .where( + 'id', + 'not in', + db.selectFrom('plugin_error_log').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(retention), + ) + .execute(); + } + + /** PR38 (uninstall cascade) / plugins.service.ts's `clearErrors` — `DELETE FROM plugin_error_log WHERE plugin_id = ?` (identical text, two call sites). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** plugins.service.ts `errors()` — `SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200`. */ + async listRecent(pluginId: string, limit = 200): Promise { + const rows = await this.find( + { plugin_id: pluginId }, + { fields: ['ts', 'level', 'message'], orderBy: [{ ts: 'desc' }, { id: 'desc' }], limit }, + ); + return rows.map((r) => ({ ts: r.ts ?? '', level: r.level, message: r.message ?? '' })); + } +} diff --git a/server/src/db/repositories/PluginMetaMigrations.repository.ts b/server/src/db/repositories/PluginMetaMigrations.repository.ts new file mode 100644 index 000000000..1c32b44b6 --- /dev/null +++ b/server/src/db/repositories/PluginMetaMigrations.repository.ts @@ -0,0 +1,16 @@ +import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR44) — the rest of this table's surface (a plugin's own + * `ctx.db.migrate` ledger) belongs to whichever later task converts `plugin-data + * .service.ts`'s host-side counterpart, if any (R-out-of-scope carve-out: the + * plugin's own sqlite file is untouched by this program). + */ +export class PluginMetaMigrationsRepository extends TrekRepository { + /** PR44 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_meta_migrations WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginOauthState.repository.ts b/server/src/db/repositories/PluginOauthState.repository.ts new file mode 100644 index 000000000..39731adf4 --- /dev/null +++ b/server/src/db/repositories/PluginOauthState.repository.ts @@ -0,0 +1,91 @@ +import type { PluginOauthState } from '../entities/PluginOauthState.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR43) — the rest of this table's surface belongs to + * Task 4's `oauth/plugin-oauth.service.ts`, which adds its own additive methods. + */ + +/** `consumeByState`'s own projection — PO5's columns plus `plugin_id` (the + * legacy SELECT's own WHERE-bound column, re-checked in JS once the atomic + * delete below no longer filters on it — see that method's docstring). */ +export interface PluginOauthStateRow { + plugin_id: string; + user_id: number; + verifier: string; + created_at: number; +} + +/** `consumeByState`'s own Kysely DB shape. */ +interface PluginOauthStateKyselyDB { + plugin_oauth_state: { + state: string; + plugin_id: string; + user_id: number; + verifier: string; + created_at: number; + }; +} + +export class PluginOauthStateRepository extends TrekRepository { + /** PR43 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_state WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * PO3 (`startConnect`'s stale-state clear, before minting a fresh one) / PO10 + * (`disconnect`) — dup text `DELETE FROM plugin_oauth_state WHERE plugin_id = ? + * AND user_id = ?`, one method for both call sites. + */ + async deleteForUser(pluginId: string, userId: number): Promise { + await this.nativeDelete({ plugin_id: pluginId, user_id: userId }); + } + + /** PO4 (`startConnect`) — `INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)`. */ + async insertState(state: string, pluginId: string, userId: number, verifier: string, createdAt: number): Promise { + await this.insert({ state, plugin_id: pluginId, user_id: userId, verifier, created_at: createdAt }); + } + + /** + * PO5+PO6+PO7 combined (`completeCallback`'s one-shot consume). The legacy + * sequence was SELECT-then-DELETE — `SELECT verifier, user_id, created_at + * FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?`, then an + * UNCONDITIONAL `DELETE FROM plugin_oauth_state WHERE state = ?` on EVERY + * path out of `completeCallback` (the refused branch and the success + * branch both delete by `state` alone) — but every statement was a + * synchronous `better-sqlite3` call with no `await` anywhere between the + * read and the delete, so two concurrent `completeCallback` calls for the + * SAME state could never interleave: JS ran one call's check+delete to + * completion before the other's synchronous block even started. + * + * Splitting that into a `findOne` (PO5) and a separate `nativeDelete` + * (PO6/PO7) — each now genuinely async through MikroORM/Kysely — reopens + * exactly that window: a second concurrent caller's read can land before + * the first caller's delete does, and BOTH would see a live row and both + * would proceed to exchange the code, spending the one-shot state twice. + * Collapsed into ONE atomic `DELETE ... RETURNING`, unconditional on + * `state` alone (matching the legacy delete's own condition exactly — + * `plugin_id`/`user_id`/TTL are re-checked by the caller against the + * RETURNED row, same as it always did against the SELECTed one), the same + * `WebauthnChallengesRepository.claimChallenge` precedent this program + * already uses for an identical single-use-consume race. Whichever + * concurrent caller's DELETE the database serialises first gets the real + * row back; the other gets `null` and fails closed — restoring the + * legacy's effectively-atomic outcome instead of reopening a TOCTOU + * window the conversion would otherwise introduce. Built on `this.kysely()` + * (validates the request context first), not `em.getKysely()` directly, + * and `.returning([...])` (not the QueryBuilder's own `.returning()` — + * `claimChallenge`'s docstring records the installed MikroORM version's + * gap on a bare DELETE). + */ + async consumeByState(state: string): Promise { + const row = await this.kysely() + .deleteFrom('plugin_oauth_state') + .where('state', '=', state) + .returning(['plugin_id', 'user_id', 'verifier', 'created_at']) + .executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/PluginOauthTokens.repository.ts b/server/src/db/repositories/PluginOauthTokens.repository.ts new file mode 100644 index 000000000..3f87a0d62 --- /dev/null +++ b/server/src/db/repositories/PluginOauthTokens.repository.ts @@ -0,0 +1,129 @@ +import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity'; +import { currentTimestampKysely } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (`plugin-runtime.service.ts`'s `uninstall()`, PR42) — the + * statement lives in Task 2's file even though the rest of this table's surface + * (`storeToken`'s COALESCE-preserving composite upsert, R-oauth-upsert) belongs to + * Task 4's `oauth/plugin-oauth.service.ts`. Task 4 adds its own methods to this + * same repository; this one is additive and does not collide with them. + */ + +/** PO8 (`getAccessToken`)'s own projection. Ciphertext columns pass through + * untouched — `access_token`/`refresh_token` are encrypted/decrypted in + * `plugin-oauth.service.ts` only (3e's R6 boundary), never in this repository. */ +export interface PluginOauthTokenRow { + access_token: string | null; + refresh_token: string | null; + expires_at: number | null; +} + +/** + * PO11's insert-only shape (`storeToken`'s VALUES list — `updated_at` is always + * `datetime('now')`/`CURRENT_TIMESTAMP`, never caller-supplied, same reasoning + * `DocumentSyncItemsRepository`'s own write-only Kysely interface docstring gives + * for a SEPARATE interface rather than widening the read-shaped row above). + */ +interface PluginOauthTokensWriteKyselyDB { + plugin_oauth_tokens: { + plugin_id: string; + user_id: number; + access_token: string | null; + refresh_token: string | null; + expires_at: number | null; + scope: string | null; + updated_at: string; + }; +} + +export class PluginOauthTokensRepository extends TrekRepository { + /** PR42 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** `exportUserData`'s GDPR fold — `SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?` (which plugins a user OAuth-linked; raw tokens are never exported). */ + async listDistinctPluginIdsForUser(userId: number): Promise { + const rows = await this.find({ user_id: userId }, { fields: ['plugin_id'] }); + return [...new Set(rows.map((r) => r.plugin_id))]; + } + + /** PO2 (`status`) — `SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL`, as a boolean existence check. */ + async hasAccessToken(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId, access_token: { $ne: null } }, { fields: ['plugin_id'] }); + return row !== null; + } + + /** PO8 (`getAccessToken`) — `SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?`. */ + async findTokenRow(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['access_token', 'refresh_token', 'expires_at'] }); + return row ? { access_token: row.access_token ?? null, refresh_token: row.refresh_token ?? null, expires_at: row.expires_at ?? null } : null; + } + + /** PO9 (`disconnect`) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?`. */ + async deleteForUser(pluginId: string, userId: number): Promise { + await this.nativeDelete({ plugin_id: pluginId, user_id: userId }); + } + + /** + * PO11 (`storeToken`, R-oauth-upsert) — the composite-key upsert on `(plugin_id, + * user_id)`. Legacy text: + * `` INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, + * refresh_token, expires_at, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?, + * datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET + * access_token = excluded.access_token, refresh_token = COALESCE(excluded.refresh_token, + * plugin_oauth_tokens.refresh_token), expires_at = excluded.expires_at, + * scope = excluded.scope, updated_at = excluded.updated_at ``. + * + * The `COALESCE` operand order is load-bearing (R-oauth-upsert's own warning, + * read twice): NEW value (`excluded.refresh_token`) FIRST, EXISTING + * (`plugin_oauth_tokens.refresh_token`) SECOND — a refresh response that DOES + * supply a new refresh token uses it; one that omits it (many providers do on a + * plain refresh grant) keeps the row's existing token. Reversing the two operands + * would silently erase stored offline access on every refresh that omits the + * field, with nothing in the request/response cycle ever surfacing it. + * + * `em.upsert` cannot express a column-level COALESCE merge (`onConflictMergeFields` + * only ever re-assigns `excluded.` verbatim or omits the column, never "merge + * with the row's own current value") — hand-typed Kysely, the same + * `insertInto(...).onConflict(oc => oc.columns([...]).doUpdateSet({...}))` shape + * `DocumentSyncItemsRepository.insertOrUpsertOnConflict` established as this + * program's precedent for a COALESCE-preserving composite upsert. Ciphertext + * passes through untouched: `access_token`/`refresh_token` are already encrypted + * by the caller (`plugin-oauth.service.ts#storeToken`, 3e's R6 boundary) — this + * repository never calls `encrypt_api_key`/`decrypt_api_key`. + */ + async storeToken(data: { + plugin_id: string; + user_id: number; + access_token: string; + refresh_token: string | null; + expires_at: number | null; + scope: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .insertInto('plugin_oauth_tokens') + .values({ + plugin_id: data.plugin_id, + user_id: data.user_id, + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at, + scope: data.scope, + updated_at: currentTimestampKysely(platform), + }) + .onConflict((oc) => + oc.columns(['plugin_id', 'user_id']).doUpdateSet({ + access_token: (eb) => eb.ref('excluded.access_token'), + refresh_token: (eb) => eb.fn.coalesce(eb.ref('excluded.refresh_token'), eb.ref('plugin_oauth_tokens.refresh_token')), + expires_at: (eb) => eb.ref('excluded.expires_at'), + scope: (eb) => eb.ref('excluded.scope'), + updated_at: () => currentTimestampKysely(platform), + }), + ) + .execute(); + } +} diff --git a/server/src/db/repositories/PluginScheduledTasks.repository.ts b/server/src/db/repositories/PluginScheduledTasks.repository.ts new file mode 100644 index 000000000..ea3312573 --- /dev/null +++ b/server/src/db/repositories/PluginScheduledTasks.repository.ts @@ -0,0 +1,141 @@ +import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** HR7's insert-only shape (`schedulerSet`'s VALUES list — `payload`/`every_ms` are always caller-supplied, never defaulted the way `id`/`created_at` are). */ +export interface PluginScheduledTaskUpsertInput { + plugin_id: string; + name: string; + due_at: number; + payload: string; + every_ms: number | null; +} + +/** PR4 — the sweep's own due-task projection. */ +export interface DueScheduledTaskRow { + id: number; + plugin_id: string; + name: string; + payload: string; + every_ms: number | null; +} + +export class PluginScheduledTasksRepository extends TrekRepository { + /** + * PR4 — `` SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks + * WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200 `` (`fireDueScheduled`'s + * sweep window). `pluginIds` — never an empty array: the caller (`fireDueScheduled`) + * already returns early when `supervisor.activeIds()` is empty, the same short-circuit + * the legacy `IN (${ph})` string-building avoided running against. + */ + async findDueForPlugins(now: number, pluginIds: string[], limit = 200): Promise { + const rows = await this.find( + { due_at: { $lte: now }, plugin_id: { $in: pluginIds } }, + { fields: ['id', 'plugin_id', 'name', 'payload', 'every_ms'], orderBy: { due_at: 'asc' }, limit }, + ); + return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, name: r.name, payload: r.payload, every_ms: r.every_ms ?? null })); + } + + /** + * PR5 — `UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?` — re-arm BEFORE + * the fire (crash-safety ordering, preserved exactly by the caller). + * + * **Deviation, named (task-2-report.md), the SAME spirit as R-uninstall's egress-host + * transaction — a second, small, deliberate correctness fix, not silent parity**: the + * legacy statement had no `due_at` guard, but the legacy CALL SITE never needed one — + * `better-sqlite3` is synchronous and the claim-then-act loop had no `await` between + * the SELECT and this UPDATE, so two "concurrent" JS calls to `fireDueScheduled` could + * never actually interleave at the DB level (the first ran to completion before the + * second's own SELECT even started). Converting the SELECT and this UPDATE into + * separately-awaited repository calls removes that accidental atomicity: two + * overlapping ticks (a real possibility once a tick can run longer than the 30s + * cadence) can both read the SAME due row before either claims it, delivering it + * TWICE (proven by `RACE-SCHED-001` before this guard was added). Adding `due_at <= + * now` here (the SAME condition the SELECT already used) makes the claim atomic and + * idempotent per row: whichever pass's UPDATE lands first moves `due_at` forward, so + * the LOSING pass's own `due_at <= now` no longer matches and its `nativeUpdate` + * affects zero rows — the caller uses that to skip delivery rather than double-fire. + */ + async rearm(id: number, dueAt: number, claimedFromDueAt: number): Promise { + const changed = await this.nativeUpdate({ id, due_at: { $lte: claimedFromDueAt } }, { due_at: dueAt }); + return changed > 0; + } + + /** PR6 — `DELETE FROM plugin_scheduled_tasks WHERE id = ?` (a one-shot task, deleted before the fire). Same atomic-claim guard as `rearm` above, same reason. */ + async deleteById(id: number, claimedFromDueAt: number): Promise { + const changed = await this.nativeDelete({ id, due_at: { $lte: claimedFromDueAt } }); + return changed > 0; + } + + /** PR37 (uninstall cascade) — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // HR5–HR8 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts`'s + // `scheduler.set`/`scheduler.cancel` RPC handlers — a plugin's own + // request to schedule/cancel one of its named tasks, distinct from PR4– + // PR10's sweep-tick statements above). + // ----------------------------------------------------------------------- + + /** HR5 — `SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?`, existence-only (the pre-quota check `schedulerSet` runs before counting). */ + async existsForPluginAndName(pluginId: string, name: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, name }, { fields: ['id'] }); + return row !== null; + } + + /** HR6 — `SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?`, the `SCHED_MAX` DoS-quota count. */ + async countForPlugin(pluginId: string): Promise { + return await this.count({ plugin_id: pluginId }); + } + + /** + * HR7 — `` INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, + * payload, every_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT (plugin_id, name) + * DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, + * every_ms = excluded.every_ms `` — the composite-key upsert on + * `(plugin_id, name)` (the entity's own `uniques`, `PluginScheduledTasks + * .entity.ts`): re-scheduling the same name replaces it in place. No + * COALESCE — every non-key column is re-assigned verbatim from `excluded` + * — so `em.upsert`'s `onConflictMergeFields` expresses it directly, + * `PluginUserConfigRepository.upsertConfig`'s own precedent for a plain + * (non-merging) composite upsert. + */ + async upsertTask(input: PluginScheduledTaskUpsertInput): Promise { + await this.upsert( + { plugin_id: input.plugin_id, name: input.name, due_at: input.due_at, payload: input.payload, every_ms: input.every_ms }, + { onConflictFields: ['plugin_id', 'name'], onConflictAction: 'merge', onConflictMergeFields: ['due_at', 'payload', 'every_ms'] }, + ); + } + + /** HR8 — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?` (`scheduler.cancel`), returning whether a row existed (`.changes > 0`). */ + async deleteByPluginAndName(pluginId: string, name: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, name }); + return changed > 0; + } + + /** + * Plan 3j Task 7 fix wave, must-land 3 (task-7-review.md): HR5 (the + * pre-write existence check), HR6 (the `SCHED_MAX` count gate) and HR7 + * (the upsert) made atomic — the SAME shape and SAME reason as + * `PluginEntityMetadataRepository.upsertValueCapped` (must-land 2): two + * concurrent `scheduler.set` calls for two different new task names on the + * same plugin could both read `count < max` before either's write landed + * (live: 130 rows on a 100 cap). `this.getEntityManager().transactional` — + * no new constructor dependency on `HostSurfaceRpc`, for the same + * `tests/helpers/plugin-host.ts` (dirty, mid-edit by a concurrent Plan 4 + * task) reason `upsertValueCapped`'s docstring gives. Returns whether the + * write happened; `false` means a NEW task name was blocked by the cap. + */ + async upsertTaskCapped(input: PluginScheduledTaskUpsertInput, maxTasks: number): Promise { + return this.getEntityManager().transactional(async () => { + const exists = await this.existsForPluginAndName(input.plugin_id, input.name); + if (!exists) { + const n = await this.countForPlugin(input.plugin_id); + if (n >= maxTasks) return false; + } + await this.upsertTask(input); + return true; + }); + } +} diff --git a/server/src/db/repositories/PluginSettingsFields.repository.ts b/server/src/db/repositories/PluginSettingsFields.repository.ts new file mode 100644 index 000000000..f04a50889 --- /dev/null +++ b/server/src/db/repositories/PluginSettingsFields.repository.ts @@ -0,0 +1,179 @@ +import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** PR#499 — `notificationChannels`' secret-user-field scan, across ALL plugins. */ +export interface PluginSecretFieldRow { + plugin_id: string; + field_key: string; +} + +/** `plugins.service.ts#settingsFields`'s own full projection (mapped to `PluginSettingsField` in the service, which also resolves `default`/`options`). */ +export interface PluginSettingsFieldRow { + field_key: string; + label: string | null; + input_type: string; + placeholder: string | null; + hint: string | null; + required: number; + secret: number; + options: string | null; + default_value: string | null; +} + +export class PluginSettingsFieldsRepository extends TrekRepository { + /** `notificationChannels`'s GDPR-export fold — `SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1`, across every plugin (no `plugin_id` filter — the caller groups by `plugin_id` itself). */ + async listSecretUserFields(): Promise { + return await this.find({ scope: 'user', secret: 1 }, { fields: ['plugin_id', 'field_key'] }); + } + + /** PS2 (`plugins.service.ts#instanceSettingsCount`) — `SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */ + async countForPluginScope(pluginId: string, scope: 'instance' | 'user'): Promise { + return await this.count({ plugin_id: pluginId, scope }); + } + + /** + * `updateInstanceConfig`'s secret-key set / `getInstanceConfig`'s secret-key set / + * `updateUserConfig`/`getUserConfig`'s `userSecretKeys` — `SELECT field_key FROM + * plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 1` (identical + * text shape across both scopes; ONE method for all four call sites). + */ + async listSecretFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find({ plugin_id: pluginId, scope, secret: 1 }, { fields: ['field_key'] }); + return rows.map((r) => r.field_key); + } + + /** + * `updateInstanceConfig`'s allowed-key set / `updateUserConfig`'s allowed-key set — + * `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ?` + * (identical text shape across both scopes; ONE method for both call sites). + */ + async listFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key'] }); + return rows.map((r) => r.field_key); + } + + /** + * `plugins.service.ts#settingsFields` (backs `userSettingsFields`/`instanceSettingsFields`) — + * `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, + * options, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? + * ORDER BY sort_order, id`. + */ + async listFields(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope }, + { + fields: ['field_key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'options', 'default_value'], + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }], + }, + ); + return rows.map((r) => ({ + field_key: r.field_key, + label: r.label ?? null, + input_type: r.input_type, + placeholder: r.placeholder ?? null, + hint: r.hint ?? null, + required: r.required, + secret: r.secret, + options: r.options ?? null, + default_value: r.default_value ?? null, + })); + } + + /** `assertRequiredFilled` — `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'`. */ + async listRequiredFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope, required: 1, input_type: { $ne: 'checkbox' } }, + { fields: ['field_key'] }, + ); + return rows.map((r) => r.field_key); + } + + /** PR34 (uninstall cascade) — `DELETE FROM plugin_settings_fields WHERE plugin_id = ?`. Unlike several of its cascade siblings, this one is NOT wrapped in try/catch by the caller — a slimmed test schema is expected to have this table. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * PU1 (Plan 3j Task 4, `plugin-user-settings.service.ts#readOne`) — `SELECT + * secret FROM plugin_settings_fields WHERE plugin_id = ? AND field_key = ? AND + * scope = 'user'`, as a boolean secret flag deciding whether `readOne` decrypts + * the stored value. + */ + async isUserFieldSecret(pluginId: string, fieldKey: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, field_key: fieldKey, scope: 'user' }, { fields: ['secret'] }); + return row?.secret === 1; + } + + /** + * `plugin-user-settings.service.ts#readAll` (Plan 3j Task 4) — `SELECT + * field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = + * ?` (`scope` is always `'user'` at this call site; kept as a parameter to match + * this repository's own established convention on every other `scope`-taking + * method here). + */ + async listFieldKeysWithSecretFlag(pluginId: string, scope: 'instance' | 'user'): Promise> { + const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key', 'secret'] }); + return rows.map((r) => ({ field_key: r.field_key, secret: r.secret })); + } + + /** + * DI8 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `` INSERT INTO + * plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, + * required, secret, scope, options, oauth_config, default_value, sort_order) VALUES + * (?,?,?,?,?,?,?,?,?,?,?,?,?) ``, looped once per manifest settings field, paired with + * DI7's `deleteAllForPlugin` above (the same delete-then-reinsert re-declare sequence). + * `insertMany`, one batched native insert — same rows the legacy per-row `.run()` loop + * produced. A no-op on an empty manifest `settings[]`. + */ + async insertFields(pluginId: string, fields: NewPluginSettingsFieldRow[]): Promise { + if (!fields.length) return; + await this.insertMany( + fields.map((f) => ({ + plugin_id: pluginId, + field_key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder, + hint: f.hint, + required: f.required, + secret: f.secret, + scope: f.scope, + options: f.options, + oauth_config: f.oauth_config, + default_value: f.default_value, + sort_order: f.sort_order, + })), + ); + } + + /** + * SD1 (Plan 3j Task 3, `settings-defaults.ts#settingDefaults`) — `SELECT field_key, + * default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND + * secret = 0 AND default_value IS NOT NULL`. Secrets never carry a default (the + * manifest parse drops it before it ever reaches a row) — the `secret = 0` filter is + * belt-and-braces, kept exactly as the legacy statement had it. + */ + async listDefaults(pluginId: string, scope: 'instance' | 'user'): Promise> { + const rows = await this.find( + { plugin_id: pluginId, scope, secret: 0, default_value: { $ne: null } }, + { fields: ['field_key', 'default_value'] }, + ); + return rows.map((r) => ({ field_key: r.field_key, default_value: r.default_value ?? '' })); + } +} + +/** DI8's own row shape — `discoverPlugins#upsert`'s per-manifest-settings-field insert. */ +export interface NewPluginSettingsFieldRow { + field_key: string; + label: string | null; + input_type: string; + placeholder: string | null; + hint: string | null; + required: number; + secret: number; + scope: string; + options: string | null; + oauth_config: string | null; + default_value: string | null; + sort_order: number; +} diff --git a/server/src/db/repositories/PluginUserConfig.repository.ts b/server/src/db/repositories/PluginUserConfig.repository.ts new file mode 100644 index 000000000..f80ac6a48 --- /dev/null +++ b/server/src/db/repositories/PluginUserConfig.repository.ts @@ -0,0 +1,51 @@ +import type { PluginUserConfig } from '../entities/PluginUserConfig.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `exportUserData`'s own per-user config scan. */ +export interface PluginUserConfigRow { + plugin_id: string; + config: string; +} + +export class PluginUserConfigRepository extends TrekRepository { + /** + * PS7/PS9/PS11 — `SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id + * = ?`, byte-identical text at THREE call sites in `plugins.service.ts` (`getUserConfig`'s + * own read, `setUserConfig`'s pre-write existence check, `getUserConfigDecrypted`'s read) + * — collapsed onto one method per the brief's own naming of this triple-duplicate. + */ + async findConfig(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['config'] }); + return row?.config ?? null; + } + + /** `exportUserData`'s GDPR fold — `SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?`. */ + async listForUser(userId: number): Promise { + const rows = await this.find({ user_id: userId }, { fields: ['plugin_id', 'config'] }); + return rows.map((r) => ({ plugin_id: r.plugin_id, config: r.config })); + } + + /** + * PS10 — `` INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) + * VALUES (?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET + * config = excluded.config, updated_at = excluded.updated_at `` — the composite-key + * upsert on `(plugin_id, user_id)`, the entity's own primary key. `currentTimestamp` + * (`src/db/dialect/sql-functions.ts`) renders the same `CURRENT_TIMESTAMP`/`datetime('now')` + * text SQLite already treats as equivalent, bound once per statement execution so the + * inserted row and the `excluded.updated_at` merge target agree, matching the legacy + * single-statement `datetime('now')` call exactly. + */ + async upsertConfig(pluginId: string, userId: number, config: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { plugin_id: pluginId, user_id: userId, config, updated_at: currentTimestamp(platform) }, + { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'merge', onConflictMergeFields: ['config', 'updated_at'] }, + ); + } + + /** PR41 (uninstall cascade) — `DELETE FROM plugin_user_config WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginUserErasureQueue.repository.ts b/server/src/db/repositories/PluginUserErasureQueue.repository.ts new file mode 100644 index 000000000..772a452b2 --- /dev/null +++ b/server/src/db/repositories/PluginUserErasureQueue.repository.ts @@ -0,0 +1,65 @@ +import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** The drain's own pending-row projection. */ +export interface PendingErasureRow { + id: number; + plugin_id: string; + user_id: number; +} + +/** `plugin_id`/`id` — the columns the orphan-scan's correlated `NOT IN (SELECT id FROM plugins)` subquery needs. */ +interface PluginUserErasureQueueKyselyDB { + plugin_user_erasure_queue: { id: number; plugin_id: string; user_id: number }; + plugins: { id: string }; +} + +export class PluginUserErasureQueueRepository extends TrekRepository { + /** PR8 — `INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)` (idempotent — a duplicate enqueue for the same pair is a no-op). */ + async insertIgnore(pluginId: string, userId: number): Promise { + await this.upsert({ plugin_id: pluginId, user_id: userId }, { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'ignore' }); + } + + /** + * `runDrainOnce`'s orphan scan — `SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue + * WHERE plugin_id NOT IN (SELECT id FROM plugins)`. A cross-table correlated `NOT IN` + * has no `FilterQuery` equivalent, so this goes through `this.kysely()`. + */ + async listOrphanPluginIds(): Promise { + const db = this.kysely(); + const rows = await db + .selectFrom('plugin_user_erasure_queue') + .select('plugin_id') + .distinct() + .where('plugin_id', 'not in', db.selectFrom('plugins').select('id')) + .execute(); + return rows.map((r) => r.plugin_id); + } + + /** + * `runDrainOnce`'s reap (a truly orphaned data dir) / `uninstall`'s `deleteData` branch — + * `DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?` (identical text, two call sites). + */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * `runDrainOnce`'s delivery window — `` SELECT id, plugin_id, user_id FROM + * plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200 ``. + * `pluginIds` — never empty: the caller already returns early when + * `supervisor.activeIds()` is empty. + */ + async findPendingForPlugins(pluginIds: string[], limit = 200): Promise { + const rows = await this.find( + { plugin_id: { $in: pluginIds } }, + { fields: ['id', 'plugin_id', 'user_id'], orderBy: { id: 'asc' }, limit }, + ); + return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, user_id: r.user_id })); + } + + /** `runDrainOnce`'s ACK — `DELETE FROM plugin_user_erasure_queue WHERE id = ?` (a delivered row). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts new file mode 100644 index 000000000..ee416f905 --- /dev/null +++ b/server/src/db/repositories/Plugins.repository.ts @@ -0,0 +1,617 @@ +import type { Plugins } from '../entities/Plugins.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 — `plugins` is this plan's most-reused table (`plugin-runtime.service.ts` + * ~28 of 53 sites, `plugins.service.ts` 5 of 13). Every method below is named after the + * statement it replaces (PR#/PS# per `plan3j-sql-inventory.md` §4/§5); several methods + * serve more than one call site that shared byte-identical SQL text in the legacy file + * (documented per method). + */ + +/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins`, the shape + * `dependencies.ts`'s dependency-graph helpers reason over (structurally compatible with + * `PluginDepRow` there; not imported here to keep `db/repositories` free of `nest/` imports). */ +export interface PluginDepListRow { + id: string; + version: string | null; + enabled: number; + dependencies: string | null; +} + +/** PR7/PR11 — `SELECT id, permissions FROM plugins` (the GDPR erasure enqueue's + the export's own full-table scan). */ +export interface PluginIdPermissionRow { + id: string; + permissions: string | null; +} + +/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */ +export interface PluginActivationGateRow { + permissions: string; + granted_permissions: string; + dependencies: string | null; + trek_range: string | null; + api_version: number | null; +} + +/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */ +export interface PluginPermissionsConfigRow { + permissions: string; + config: string; +} + +/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?` (`update`'s pre-update snapshot). */ +export interface PluginPreUpdateRow { + enabled: number; + granted_permissions: string; + version: string | null; +} + +/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?` (`notificationChannels`). */ +export interface PluginNameCapabilitiesRow { + name: string; + capabilities: string; +} + +/** PFC1 — `plugins-feed.controller.ts#list`'s own client-facing feed projection. */ +export interface PluginFeedRow { + id: string; + name: string; + type: string; + icon: string | null; + capabilities: string; + granted_permissions: string; +} + +/** PPS1 — `plugin-pois.service.ts#available`'s active-provider projection (#1781). */ +export interface PluginActiveNameCapabilitiesRow { + id: string; + name: string; + capabilities: string; +} + +/** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */ +export interface PluginAdminRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string | null; + version: string | null; + status: string; + enabled: number; + last_error: string | null; + reviewed_at: string | null; + source_repo: string | null; + permissions: string; + capabilities: string; + dependencies: string | null; + operator_egress: number; + trek_range: string | null; + author_pubkey: string | null; + update_block_code: string | null; + update_block_detail: string | null; + update_block_version: string | null; + update_hold: number; +} + +export class PluginsRepository extends TrekRepository { + // ----------------------------------------------------------------------- + // PR1 — status/log persistence (onStatus callback, lazily resolved — §4a) + // ----------------------------------------------------------------------- + + /** PR1 — `UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async updateStatusAndError(id: string, status: string, lastError: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status, last_error: lastError, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // PR7 / PR11 — GDPR erasure enqueue + export's own full-table scan + // ----------------------------------------------------------------------- + + /** PR7/PR11 — `SELECT id, permissions FROM plugins` (identical text, two call sites: `enqueueUserErasure`, `exportUserData`). */ + async listIdsAndPermissions(): Promise { + const rows = await this.find({}, { fields: ['id', 'permissions'] }); + // `Plugins.id` is typed `string | null` at the entity level (accommodates a + // pre-insert state MikroORM's own typing requires) but is the table's real + // PRIMARY KEY — never null on a row read back from the DB (same shape as + // `listForAdmin`'s `id: r.id ?? ''` below). + return rows.map((r) => ({ id: r.id ?? '', permissions: r.permissions ?? null })); + } + + // ----------------------------------------------------------------------- + // PR15 / PR16 / PR25 — dependency-graph reads + // ----------------------------------------------------------------------- + + /** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins` (identical text, three call sites: `installedDepRows`, `deactivateForDisabledAddon`, `deactivateWithDependents`). */ + async listDepRows(): Promise { + const rows = await this.find({}, { fields: ['id', 'version', 'enabled', 'dependencies'] }); + return rows.map((r) => ({ id: r.id ?? '', version: r.version ?? null, enabled: r.enabled, dependencies: r.dependencies ?? null })); + } + + // ----------------------------------------------------------------------- + // PR17 — activation gate + // ----------------------------------------------------------------------- + + /** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?`. */ + async findActivationGate(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] }); + return row + ? { + permissions: row.permissions ?? '[]', + granted_permissions: row.granted_permissions ?? '', + dependencies: row.dependencies ?? null, + trek_range: row.trek_range ?? null, + api_version: row.api_version ?? null, + } + : null; + } + + // ----------------------------------------------------------------------- + // PR18 — spawnActivated + // ----------------------------------------------------------------------- + + /** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?`. */ + async findPermissionsAndConfig(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions', 'config'] }); + return row ? { permissions: row.permissions ?? '[]', config: row.config ?? '{}' } : null; + } + + // ----------------------------------------------------------------------- + // PR19 — spawnActivated's admin-intent write + // ----------------------------------------------------------------------- + + /** PR19 — `UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?`. */ + async grantPermissionsAndEnable(id: string, grantedPermissionsJson: string): Promise { + await this.nativeUpdate({ id }, { granted_permissions: grantedPermissionsJson, enabled: 1 }); + } + + // ----------------------------------------------------------------------- + // PR21 — wantsOperatorEgress + // ----------------------------------------------------------------------- + + /** PR21 — `SELECT operator_egress FROM plugins WHERE id = ?`. */ + async findOperatorEgressFlag(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['operator_egress'] }); + return row?.operator_egress ?? null; + } + + // ----------------------------------------------------------------------- + // PR24 — deactivate / markInactive + // ----------------------------------------------------------------------- + + /** PR24 — `UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async markInactive(id: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status: 'inactive', enabled: 0, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // PR26 — update's pre-update snapshot + // ----------------------------------------------------------------------- + + /** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?`. */ + async findPreUpdateSnapshot(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['enabled', 'granted_permissions', 'version'] }); + return row ? { enabled: row.enabled, granted_permissions: row.granted_permissions ?? '', version: row.version ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR27 — update's post-install re-read of declared permissions + // ----------------------------------------------------------------------- + + /** PR27 — `SELECT permissions FROM plugins WHERE id = ?`. */ + async findPermissions(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions'] }); + return row?.permissions ?? null; + } + + // ----------------------------------------------------------------------- + // PR28 — retrust's signing-key TOFU comparison + // ----------------------------------------------------------------------- + + /** PR28 — `SELECT author_pubkey FROM plugins WHERE id = ?`. Security-sensitive: feeds the TOFU comparison. */ + async findAuthorPubkey(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['author_pubkey'] }); + return row?.author_pubkey ?? null; + } + + // ----------------------------------------------------------------------- + // PR29 — sideload's replace-existing check + // ----------------------------------------------------------------------- + + /** PR29 — `SELECT id FROM plugins WHERE id = ?`, as a boolean existence check. */ + async existsById(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['id'] }); + return row !== null; + } + + // ----------------------------------------------------------------------- + // PR30 / PR32 — dev-link install + reload's source_repo read + // ----------------------------------------------------------------------- + + /** + * PR30/PR32 — `SELECT source_repo FROM plugins WHERE id = ?` (identical text, two call + * sites: `link`, `reload`). Returns the ROW, not the bare column: both callers need "no + * row" (`null`) told apart from "row present with `source_repo` unset" (`{ source_repo: + * null }`) — a plugin installed through the registry/sideload (never dev-linked) has a + * real row with a NULL `source_repo`, and both legacy call sites treat that as "already + * installed"/"not dev-linked", not as "not found". + */ + async findSourceRepoRow(id: string): Promise<{ source_repo: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['source_repo'] }); + return row ? { source_repo: row.source_repo ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR31 — dev-link install's cascade clear + // ----------------------------------------------------------------------- + + /** + * PR31 — `` UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, + * author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL, + * update_block_version = NULL, status = 'inactive', enabled = 0 WHERE id = ? `` — the + * plugin has left the registry trust model, so every registry-trust column is cleared + * in the same statement that re-points `source_repo` to the dev-link marker. + */ + async clearForDevLink(id: string, sourceRepo: string): Promise { + await this.nativeUpdate( + { id }, + { + source_repo: sourceRepo, + source_commit: null, + sha256: null, + author_pubkey: null, + update_block_code: null, + update_block_detail: null, + update_block_version: null, + status: 'inactive', + enabled: 0, + }, + ); + } + + // ----------------------------------------------------------------------- + // PR33 — uninstall cascade's own row + // ----------------------------------------------------------------------- + + /** PR33 — `DELETE FROM plugins WHERE id = ?` (the uninstall cascade's first statement). */ + async deleteById(id: string): Promise { + await this.nativeDelete({ id }); + } + + // ----------------------------------------------------------------------- + // PR47 — the inter-plugin call/event router's grant check + // ----------------------------------------------------------------------- + + /** PR47 — `SELECT granted_permissions FROM plugins WHERE id = ?`. */ + async findGrantedPermissions(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['granted_permissions'] }); + return row?.granted_permissions ?? null; + } + + // ----------------------------------------------------------------------- + // PR48 / PR49 — dependsOnSatisfied (caller + target side) + // ----------------------------------------------------------------------- + + /** PR48 — `SELECT dependencies FROM plugins WHERE id = ?` (the caller side of `dependsOnSatisfied`). `null` return means "no row" — `dependsOnSatisfied` refuses on a missing caller the same as a missing target. */ + async findDependenciesRow(id: string): Promise<{ dependencies: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['dependencies'] }); + return row ? { dependencies: row.dependencies ?? null } : null; + } + + /** PR49 — `SELECT version FROM plugins WHERE id = ?` (the target side of `dependsOnSatisfied`). Distinct return shape from `findDependenciesColumn`: this one must tell "no row" apart from "row with a null version", which `dependsOnSatisfied` needs (a vanished target plugin refuses, a real one with no version falls back to `'0.0.0'` in the caller). */ + async findVersionRow(id: string): Promise<{ version: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['version'] }); + return row ? { version: row.version ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR51 — notificationChannels + // ----------------------------------------------------------------------- + + /** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?`. */ + async findNameAndCapabilities(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['name', 'capabilities'] }); + return row ? { name: row.name, capabilities: row.capabilities } : null; + } + + // ----------------------------------------------------------------------- + // PR52 / PR53 — capabilityList / mcpToolCapabilities + // ----------------------------------------------------------------------- + + /** PR52/PR53 — `SELECT capabilities FROM plugins WHERE id = ?` (identical text, two call sites: `capabilityList`, `mcpToolCapabilities`). */ + async findCapabilities(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['capabilities'] }); + return row?.capabilities ?? null; + } + + // ----------------------------------------------------------------------- + // PS-list — plugins.service.ts#list() + // ----------------------------------------------------------------------- + + /** + * PS-list — `plugins.service.ts#list()`'s own admin-listing SELECT: `SELECT id, name, + * description, type, icon, version, status, enabled, last_error, reviewed_at, + * source_repo, permissions, capabilities, dependencies, operator_egress, trek_range, + * author_pubkey, update_block_code, update_block_detail, update_block_version, + * update_hold FROM plugins ORDER BY sort_order, name`. + */ + async listForAdmin(): Promise { + const rows = await this.find( + {}, + { + fields: [ + 'id', 'name', 'description', 'type', 'icon', 'version', 'status', 'enabled', 'last_error', 'reviewed_at', 'source_repo', + 'permissions', 'capabilities', 'dependencies', 'operator_egress', 'trek_range', 'author_pubkey', + 'update_block_code', 'update_block_detail', 'update_block_version', 'update_hold', + ], + orderBy: [{ sort_order: 'asc' }, { name: 'asc' }], + }, + ); + return rows.map((r) => ({ + id: r.id ?? '', + name: r.name, + description: r.description ?? null, + type: r.type, + icon: r.icon ?? null, + version: r.version ?? null, + status: r.status, + enabled: r.enabled, + last_error: r.last_error ?? null, + reviewed_at: r.reviewed_at ?? null, + source_repo: r.source_repo ?? null, + permissions: r.permissions ?? '[]', + capabilities: r.capabilities, + dependencies: r.dependencies ?? null, + operator_egress: r.operator_egress, + trek_range: r.trek_range ?? null, + author_pubkey: r.author_pubkey ?? null, + update_block_code: r.update_block_code ?? null, + update_block_detail: r.update_block_detail ?? null, + update_block_version: r.update_block_version ?? null, + update_hold: r.update_hold, + })); + } + + // ----------------------------------------------------------------------- + // PS4 — resumeUpdates / clearUpdateHold + // ----------------------------------------------------------------------- + + /** PS4 — `UPDATE plugins SET update_hold = 0 WHERE id = ?`, returning whether a row existed (`.changes > 0`). */ + async clearUpdateHold(id: string): Promise { + const changed = await this.nativeUpdate({ id }, { update_hold: 0 }); + return changed > 0; + } + + // ----------------------------------------------------------------------- + // PS5 / PS13 — getInstanceConfig / updateInstanceConfig's config read + // ----------------------------------------------------------------------- + + /** PS5/PS13 — `SELECT config FROM plugins WHERE id = ?` (identical text, two call sites: `updateInstanceConfig`, `getInstanceConfig`). */ + async findConfig(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['config'] }); + return row?.config ?? null; + } + + // ----------------------------------------------------------------------- + // PS6 — updateInstanceConfig's write / plugins.service's setConfig + // ----------------------------------------------------------------------- + + /** PS6 — `UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `config` already encrypted by the caller (3e's R6 pattern) — this repository never calls `encrypt_api_key`/`decrypt_api_key`. */ + async setConfig(id: string, config: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — install/discovery.ts's manifest ingestion (DI2–DI4; + // DI2's existence check reuses `existsById` above) + // ----------------------------------------------------------------------- + + /** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ + async insertManifest(m: PluginManifestRow): Promise { + await this.insert({ + id: m.id, + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + // '' (empty, not '[]') marks "never consented" so the first activation is + // distinguishable from a plugin consented to zero permissions. + granted_permissions: '', + status: 'inactive', + }); + } + + /** + * DI4 — `` UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, + * api_version = ?, min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, + * dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``. + * Deliberately narrower than `insertManifest`'s column set — `status`/`granted_permissions`/ + * `config` are NEVER touched here, so a re-discovery of an already-installed plugin never + * silently re-activates it or wipes its settings/consent. + */ + async updateManifestFields(id: string, m: Omit): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + updated_at: currentTimestamp(platform), + }, + ); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — signature-status.ts's setUpdateBlock/clearUpdateBlock (SG1/SG2) + // ----------------------------------------------------------------------- + + /** SG1 — `UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?` (`setUpdateBlock`, a signature-verification failure). */ + async setUpdateBlockColumns(id: string, code: string, detail: string, version: string | null): Promise { + await this.nativeUpdate({ id }, { update_block_code: code, update_block_detail: detail, update_block_version: version }); + } + + /** SG2 — `UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?` (`clearUpdateBlock`, the inverse — a successful install/update). */ + async clearUpdateBlockColumns(id: string): Promise { + await this.nativeUpdate({ id }, { update_block_code: null, update_block_detail: null, update_block_version: null }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — registry/registry.service.ts's own statements (RG#). + // RG9 (`verifySignatureAndTofu`'s pinned-key read) reuses `findAuthorPubkey` + // above (identical text to PR28) rather than adding a duplicate method. + // ----------------------------------------------------------------------- + + /** RG2 (`install`'s post-discover provenance write) — `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?`. */ + async setInstallProvenance(id: string, sourceRepo: string, sourceCommit: string, sha256: string, reviewedAt: string | null): Promise { + await this.nativeUpdate({ id }, { source_repo: sourceRepo, source_commit: sourceCommit, sha256, reviewed_at: reviewedAt }); + } + + /** RG3 (`install`'s TOFU pin) — `UPDATE plugins SET author_pubkey = ? WHERE id = ?`. Security-sensitive: only ever called with a key the artifact just verified under (see `registry.service.ts#install`'s own docstring) — never cleared to NULL by this method. */ + async setAuthorPubkey(id: string, authorPubkey: string): Promise { + await this.nativeUpdate({ id }, { author_pubkey: authorPubkey }); + } + + /** RG5 (`installWithDependencies`'s already-installed set) — `SELECT id FROM plugins`. */ + async listAllIds(): Promise { + const rows = await this.find({}, { fields: ['id'] }); + return rows.map((r) => r.id ?? ''); + } + + /** RG6 (`recomputeUpdateHold`) — `UPDATE plugins SET update_hold = ? WHERE id = ?`. */ + async setUpdateHold(id: string, hold: boolean): Promise { + await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 }); + } + + /** + * RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?, + * source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL, + * update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, + * status = 'inactive', enabled = 0 WHERE id = ? ``. The sideload twin of PR31's + * `clearForDevLink` above — same registry-trust-column clear, plus `reviewed_at` + * (a sideload was never registry-reviewed) — kept as its own method rather than + * widening `clearForDevLink`, since the two back separate legacy statements with + * different column sets, not one shared text. + */ + async clearForSideload(id: string, sourceRepo: string): Promise { + await this.nativeUpdate( + { id }, + { + source_repo: sourceRepo, + source_commit: null, + sha256: null, + reviewed_at: null, + author_pubkey: null, + update_block_code: null, + update_block_detail: null, + update_block_version: null, + status: 'inactive', + enabled: 0, + }, + ); + } + + /** RG11 (`assertRetrustable`) — `SELECT source_repo, author_pubkey FROM plugins WHERE id = ?`. */ + async findSourceRepoAndAuthorPubkey(id: string): Promise<{ source_repo: string | null; author_pubkey: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] }); + return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null; + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 5 — the small plugin controllers' own statements (PFC1, + // PUC1/POC1). PAC1/PRF1 (the audit-read/append delegates) are Task 3's + // `plugin-audit.ts` conversion, not this repository's. + // ----------------------------------------------------------------------- + + /** + * PFC1 (`plugins-feed.controller.ts#list`) — `SELECT id, name, type, icon, + * capabilities, granted_permissions FROM plugins WHERE status = 'active' + * ORDER BY sort_order, name`. This row goes to the CLIENT, not a plugin + * process — R-facade's `withCamelCase` wrapper is never applied here (the + * client already speaks snake_case per the wire contract, D1); stays + * exactly as read. + */ + async findActiveFeedRows(): Promise { + const rows = await this.find( + { status: 'active' }, + { fields: ['id', 'name', 'type', 'icon', 'capabilities', 'granted_permissions'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] }, + ); + return rows.map((r) => ({ + id: r.id ?? '', + name: r.name, + type: r.type, + icon: r.icon ?? null, + capabilities: r.capabilities, + granted_permissions: r.granted_permissions ?? '', + })); + } + + /** + * PUC1/POC1 — `SELECT 1 FROM plugins WHERE id = ? AND status = 'active'`, + * byte-identical text, two call sites (`plugin-user-settings.controller.ts + * #activeWithUserFields`, `oauth/plugin-oauth.controller.ts#isActive`) — + * one shared method for both, existence-only. + */ + async existsActive(id: string): Promise { + const row = await this.findOne({ id, status: 'active' }, { fields: ['id'] }); + return row !== null; + } + + // ----------------------------------------------------------------------- + // PPS1 — plugin-pois.service.ts#available (#1781); PPS2 reuses PR52's + // findCapabilities. + // ----------------------------------------------------------------------- + + /** + * PPS1 (`plugin-pois.service.ts#available`, #1781) — `SELECT id, name, capabilities + * FROM plugins WHERE status = 'active' ORDER BY sort_order, name`. The explore + * pill's category list, in the order the feed lists plugins. PPS2 (`declared`) + * reuses `findCapabilities` (PR52's identical text). + */ + async listActiveNameCapabilities(): Promise { + const rows = await this.find( + { status: 'active' }, + { fields: ['id', 'name', 'capabilities'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] }, + ); + return rows.map((r) => ({ id: r.id ?? '', name: r.name, capabilities: r.capabilities })); + } +} + +/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ +export interface PluginManifestRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string; + version: string | null; + api_version: number; + min_trek_version: string | null; + trek_range: string | null; + permissions: string; + capabilities: string; + dependencies: string; + operator_egress: number; +} diff --git a/server/src/db/repositories/PushSubscriptions.repository.ts b/server/src/db/repositories/PushSubscriptions.repository.ts new file mode 100644 index 000000000..662d80ad2 --- /dev/null +++ b/server/src/db/repositories/PushSubscriptions.repository.ts @@ -0,0 +1,177 @@ +import type { PushSubscriptions } from '../entities/PushSubscriptions.entity'; +import { columnIncrementedBy, currentTimestamp, currentTimestampKysely } from '../dialect/sql-functions'; +import type { AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `push_subscriptions` row exactly as `SELECT *` reads it (Web Push, #894). */ +export interface PushSubscriptionRow { + id: number; + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + created_at: string; + last_success_at: string | null; + failure_count: number; +} + +const _pushSubscriptionRowKeys: AssertRowKeys = true; + +interface PushSubscriptionsKyselyDB { + push_subscriptions: PushSubscriptionRow; +} + +/** The columns PS1's INSERT binds; the rest come from their defaults. */ +interface PushSubscriptionsInsertKyselyDB { + push_subscriptions: { + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + created_at: string; + failure_count: number; + }; +} + +/** `push_subscriptions` — one row per browser a user switched Web Push on in. */ +export class PushSubscriptionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + /** + * PS1 (`PushSubscriptionsService.upsert`) — + * ```sql + * INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent) + * VALUES (?, ?, ?, ?, ?, ?) + * ON CONFLICT(endpoint) DO UPDATE SET + * user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, + * vapid_public_key = excluded.vapid_public_key, user_agent = excluded.user_agent, + * created_at = CURRENT_TIMESTAMP, failure_count = 0 + * ``` + * `created_at`/`failure_count` are bound on the insert half too, with the + * values their column defaults would give (`CURRENT_TIMESTAMP`, `0`), so the + * fresh row reads exactly as the legacy one did. + */ + async upsertSubscription(data: { + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .insertInto('push_subscriptions') + .values({ ...data, created_at: currentTimestampKysely(platform), failure_count: 0 }) + .onConflict((oc) => + oc.column('endpoint').doUpdateSet({ + user_id: (eb) => eb.ref('excluded.user_id'), + p256dh: (eb) => eb.ref('excluded.p256dh'), + auth: (eb) => eb.ref('excluded.auth'), + vapid_public_key: (eb) => eb.ref('excluded.vapid_public_key'), + user_agent: (eb) => eb.ref('excluded.user_agent'), + created_at: () => currentTimestampKysely(platform), + failure_count: 0, + }), + ) + .execute(); + } + + /** + * PS2 (`PushSubscriptionsService.upsert`, the device cap) — + * ```sql + * DELETE FROM push_subscriptions WHERE id IN ( + * SELECT id FROM push_subscriptions WHERE user_id = ? AND endpoint <> ? + * ORDER BY created_at ASC, id ASC LIMIT ?) + * ``` + */ + async deleteOldestForUser(userId: number, keepEndpoint: string, limit: number): Promise { + await this.readDb() + .deleteFrom('push_subscriptions') + .where('id', 'in', (eb) => + eb + .selectFrom('push_subscriptions') + .select('id') + .where('user_id', '=', userId) + .where('endpoint', '<>', keepEndpoint) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc') + .limit(limit), + ) + .execute(); + } + + /** PS3 (`removeForUser`) — `DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`; answers whether a row went. */ + async deleteForUserEndpoint(userId: number, endpoint: string): Promise { + return (await this.nativeDelete({ user: userId, endpoint })) > 0; + } + + /** PS4 (`listForUser`) — `SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('push_subscriptions') + .selectAll() + .where('user_id', '=', userId) + .orderBy('id') + .execute(); + } + + /** PS5 (`countForUser`) — `SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?`. */ + async countForUser(userId: number): Promise { + return await this.count({ user: userId }); + } + + /** PS6 (`hasAny`) — `SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1`. */ + async hasAnyForUser(userId: number): Promise { + const row = await this.readDb() + .selectFrom('push_subscriptions') + .select('id') + .where('user_id', '=', userId) + .limit(1) + .executeTakeFirst(); + return row !== undefined; + } + + /** + * `DELETE FROM push_subscriptions WHERE user_id = ?` — a password change, a + * reset or an admin setting a password drops every device with the other + * credentials (AuthService, AdminService), since a device outlives every session. + */ + async deleteAllForUser(userId: number): Promise { + await this.nativeDelete({ user: userId }); + } + + /** PS7 (`deleteById`) — `DELETE FROM push_subscriptions WHERE id = ?`. */ + async deleteSubscription(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** PS8 (`recordSuccess`) — `UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?`. */ + async recordSuccess(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { last_success_at: currentTimestamp(platform), failure_count: 0 }); + } + + /** + * PS9 (`recordFailure`) — + * `UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count`. + * One statement, so two sends at once cannot both see the old count. 0 when + * the row is gone already. + */ + async incrementFailureCount(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const result = await this.qb() + .update({ failure_count: columnIncrementedBy(platform, 'failure_count', 1) }) + .where({ id }) + .returning('failure_count') + .execute('run'); + const row = result.row as { failure_count?: number } | undefined; + return row?.failure_count ?? 0; + } +} diff --git a/server/src/db/repositories/ReservationDayPositions.repository.ts b/server/src/db/repositories/ReservationDayPositions.repository.ts new file mode 100644 index 000000000..0ac75b16d --- /dev/null +++ b/server/src/db/repositories/ReservationDayPositions.repository.ts @@ -0,0 +1,79 @@ +import type { ReservationDayPositions } from '../entities/ReservationDayPositions.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `reservation_day_positions`/`reservations`/`days`'s shape for RS31's Kysely upsert. */ +interface ReservationDayPositionsKyselyDB { + reservation_day_positions: { + reservation_id: number; + day_id: number; + position: number; + }; + reservations: { + id: number; + // `number | string`: the legacy statement raw-binds `tripId` exactly as + // the caller received it (D4's T5 escape hatch) — the Kysely column type + // here is widened to accept both without narrowing which non-canonical + // ids match, never actually persisted (this table has no `trip_id` + // column of its own). + trip_id: number | string; + }; + days: { + id: number; + trip_id: number | string; + }; +} + +export class ReservationDayPositionsRepository extends TrekRepository { + /** + * RS19 (`ReservationsService.list`) — `SELECT rdp.reservation_id, + * rdp.day_id, rdp.position FROM reservation_day_positions rdp JOIN + * reservations r ON rdp.reservation_id = r.id WHERE r.trip_id = ?`. The + * fold into `{[day_id]: position}` per reservation stays in the service. + */ + async listForTrip(trip_id: number): Promise<{ reservation_id: number; day_id: number; position: number }[]> { + const platform = this.getEntityManager().getPlatform(); + // `rdp.reservation_id`/`rdp.day_id` are `persist(false)` mirrors of the + // `reservation`/`day` relations — bare-selecting them alongside an + // active JOIN on `rdp.reservation` (aliased `r`) is the exact trap + // `DayAssignmentsRepository.assignmentWithPlaceSelect`'s `category_id` + // docstring documents (the column silently aliases off the JOINED + // entity instead); `columnRef` selects the literal physical column. + return this.qb('rdp') + .join('rdp.reservation', 'r') + .select([columnRef(platform, 'rdp.reservation_id').as('reservation_id'), columnRef(platform, 'rdp.day_id').as('day_id'), 'rdp.position']) + .where({ 'r.trip': trip_id }) + .execute<{ reservation_id: number; day_id: number; position: number }[]>('all', false); + } + + /** + * RS31 (`ReservationsService.updatePositions`'s day branch) — `INSERT OR + * REPLACE INTO reservation_day_positions (reservation_id, day_id, + * position) SELECT r.id, d.id, ? FROM reservations r JOIN days d ON + * d.trip_id = r.trip_id WHERE r.id = ? AND d.id = ? AND r.trip_id = ?`. + * + * Kysely `insertInto().orReplace().columns().expression(selectFrom…)` + * (the MikroORM QueryBuilder's `insertFrom` has no `OR REPLACE` flag — + * `.onConflict().merge()` is semantically different, a real UPDATE rather + * than REPLACE's delete-then-insert). The trip scoping lives IN the + * SELECT (a foreign `reservation_id`/`day_id` materialises zero rows to + * insert, never a foreign-key error) — preserved exactly, not hoisted + * into a pre-check. + */ + async upsertScoped(trip_id: number | string, reservation_id: number, day_id: number, position: number): Promise { + await this.kysely() + .insertInto('reservation_day_positions') + .orReplace() + .columns(['reservation_id', 'day_id', 'position']) + .expression((eb) => + eb + .selectFrom('reservations as r') + .innerJoin('days as d', 'd.trip_id', 'r.trip_id') + .select(['r.id as reservation_id', 'd.id as day_id', eb.val(position).as('position')]) + .where('r.id', '=', reservation_id) + .where('d.id', '=', day_id) + .where('r.trip_id', '=', trip_id), + ) + .execute(); + } +} diff --git a/server/src/db/repositories/ReservationEndpoints.repository.ts b/server/src/db/repositories/ReservationEndpoints.repository.ts new file mode 100644 index 000000000..9907e5cad --- /dev/null +++ b/server/src/db/repositories/ReservationEndpoints.repository.ts @@ -0,0 +1,286 @@ +import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { columnRef } from '../dialect/sql-functions'; +import { travelerOwnsExpr, type ReservationTravelersOwnsKyselyDB } from './_shared/reservation-travelers-owns'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `reservation_endpoints` row exactly as `r.*`/`SELECT *` read it (every scalar column). */ +export interface ReservationEndpointRow { + id: number; + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_time: string | null; + local_date: string | null; + created_at: string | null; +} + +const _reservationEndpointRowKeys: AssertRowKeys = true; + +export class ReservationEndpointsRepository extends TrekRepository { + /** + * RS3 (`ReservationsService.loadEndpointsByTrip`) — `SELECT e.* FROM + * reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id + * WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`. The + * per-reservation grouping into a `Map` stays in the service (JS, not SQL). + */ + async listForTrip(trip_id: number): Promise { + return this.qb('e') + .join('e.reservation', 'r') + .select(['e.*']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'e.reservation': 'asc', 'e.sequence': 'asc' }) + .execute('all', false); + } + + /** + * RR2 (`ReservationsReadService.loadEndpoints`) — `SELECT * FROM + * reservation_endpoints WHERE reservation_id = ? ORDER BY sequence`. + */ + async listForReservation(reservation_id: number): Promise { + return this.qb('e') + .select(['e.*']) + .where({ reservation: reservation_id }) + .orderBy({ sequence: 'asc' }) + .execute('all', false); + } + + /** RS16 (`ReservationsService.saveEndpoints`'s replace-all) — `DELETE FROM reservation_endpoints WHERE reservation_id = ?`. */ + async deleteForReservation(reservation_id: number): Promise { + await this.nativeDelete({ reservation: reservation_id }); + } + + /** + * RS17 — `INSERT INTO reservation_endpoints (reservation_id, role, + * sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES + * (?×10)`, one row per call (the legacy `prepare` + per-row `run`). The + * lat/lng-null skip and the `sequence ?? i` (index AFTER the filter) stay + * in the service — this writes exactly the row it is handed. + */ + async insertEndpoint(input: { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_time: string | null; + local_date: string | null; + }): Promise { + return await this.insert({ + reservation: input.reservation_id, + role: input.role, + sequence: input.sequence, + name: input.name, + code: input.code, + lat: input.lat, + lng: input.lng, + timezone: input.timezone, + local_time: input.local_time, + local_date: input.local_date, + }); + } + + /** DY17 (`DaysService.restampReservationDates`) — `SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?`. */ + async listIdAndDate(reservation_id: number): Promise<{ id: number; local_date: string | null }[]> { + return this.qb('e') + .select(['e.id', 'e.local_date']) + .where({ reservation: reservation_id }) + .execute<{ id: number; local_date: string | null }[]>('all', false); + } + + /** DY18 — `UPDATE reservation_endpoints SET local_date = ? WHERE id = ?`. */ + async setLocalDate(id: number, local_date: string): Promise { + await this.nativeUpdate({ id }, { local_date }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (`airtrail-import.service.ts`) — additive, per this task's + // own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * ATI3 (`airtrail-import.service.ts#importAirtrailFlights`) — `SELECT + * e.reservation_id, e.code, e.local_date, e.sequence FROM + * reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + * WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`. Distinct + * from {@link listForTrip} above (RS3): a narrower column list, a + * `r.type = 'flight'` filter and a single-key `ORDER BY e.sequence` (not + * `e.reservation_id, e.sequence`) — a genuinely different statement, per + * D4. + */ + async listFlightEndpointsForTrip(trip_id: number): Promise<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('e') + .join('e.reservation', 'r') + .select([columnRef(platform, 'e.reservation_id').as('reservation_id'), 'e.code', 'e.local_date', 'e.sequence']) + .where({ 'r.trip': trip_id, 'r.type': 'flight' }) + .orderBy({ sequence: 'asc' }) + .execute<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive, append-only per that task's + // own file-ownership rule. AT6/AT45/AT46 all consume Task 0's shared + // `travelerOwnsExpr` (`_shared/reservation-travelers-owns.ts`, R7) rather + // than re-deriving the `TRAVELER_OWNS` fragment — one predicate, three + // call sites, per the plan's own instruction. + // --------------------------------------------------------------------------- + + /** + * AT6 (`AtlasService#stats`, countries reached only by a flight — + * #1366/#1486/#1535/#1966) — `SELECT DISTINCT e.id, e.reservation_id, + * r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time, + * r.type AS reservation_type, r.status AS reservation_status, CASE e.role + * WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) + * ELSE COALESCE(r.reservation_time, r.reservation_end_time) END AS + * fallback_time FROM reservation_endpoints e JOIN reservations r ON + * e.reservation_id = r.id WHERE r.trip_id IN (...) AND e.role IN ('from', + * 'to') AND ${TRAVELER_OWNS}`. Dynamic `IN`, empty-array short-circuit — + * the caller (`getUserTrips`'s own tripIds) already guards the zero-trip + * case before this ever runs, but the guard is repeated here to match + * every other dynamic-`IN` site in this cluster (AT2/AT3/AT23/AT28). + */ + async listOwnedEndpointsForTrips(tripIds: number[], userId: number): Promise { + if (tripIds.length === 0) return []; + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .select((eb) => [ + 'e.id', 'e.reservation_id', 'r.trip_id', 'e.role', 'e.code', 'e.lat', 'e.lng', 'e.local_date', 'e.local_time', + 'r.type as reservation_type', 'r.status as reservation_status', + eb.case().when('e.role', '=', 'to').then(eb.fn.coalesce('r.reservation_end_time', 'r.reservation_time')).else(eb.fn.coalesce('r.reservation_time', 'r.reservation_end_time')).end().as('fallback_time'), + ]) + .distinct() + .where('r.trip_id', 'in', tripIds) + .where('e.role', 'in', ['from', 'to']) + .where((eb) => travelerOwnsExpr(eb, userId)) + .execute(); + return rows as TravelerOwnedEndpointRow[]; + } + + /** + * AT45 (`AtlasService#getTravelStats`) — the widest single statement in + * this plan: `SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, + * e.code, e.lat, e.lng, e.local_date, e.local_time, r.type AS + * reservation_type, r.status AS reservation_status, CASE e.role WHEN + * 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) ELSE + * COALESCE(r.reservation_time, r.reservation_end_time) END AS + * fallback_time FROM reservation_endpoints e JOIN reservations r ON + * e.reservation_id = r.id JOIN trips t ON r.trip_id = t.id LEFT JOIN + * trip_members tm ON t.id = tm.trip_id WHERE (t.user_id = ? OR tm.user_id + * = ?) AND e.role IN ('from', 'to') AND COALESCE(t.start_date, t.end_date) + * IS NOT NULL AND COALESCE(t.start_date, t.end_date) <= date('now') AND + * ${TRAVELER_OWNS}`. `today` resolved by the caller once (`todayUtc()`), + * the `TripsRepository.activeTrip`/`lastStartedTrip` precedent. + */ + async listOwnedEndpointsForUser(userId: number, today: string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .innerJoin('trips as t', 't.id', 'r.trip_id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select((eb) => [ + 'e.id', 'e.reservation_id', 'r.trip_id', 'e.role', 'e.code', 'e.lat', 'e.lng', 'e.local_date', 'e.local_time', + 'r.type as reservation_type', 'r.status as reservation_status', + eb.case().when('e.role', '=', 'to').then(eb.fn.coalesce('r.reservation_end_time', 'r.reservation_time')).else(eb.fn.coalesce('r.reservation_time', 'r.reservation_end_time')).end().as('fallback_time'), + ]) + .distinct() + .where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', '=', userId)])) + .where('e.role', 'in', ['from', 'to']) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), 'is not', null)) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', today)) + .where((eb) => travelerOwnsExpr(eb, userId)) + .execute(); + return rows as TravelerOwnedEndpointRow[]; + } + + /** + * AT46 (`AtlasService#flightDistanceKm`) — `SELECT re.reservation_id, + * re.lat, re.lng FROM reservation_endpoints re JOIN reservations r ON + * r.id = re.reservation_id JOIN trips t ON t.id = r.trip_id LEFT JOIN + * trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? WHERE + * (t.user_id = ? OR tm.user_id IS NOT NULL) AND r.type = 'flight' AND + * r.status != 'cancelled' AND ${TRAVELER_OWNS} ORDER BY re.reservation_id, + * re.sequence`. The haversine summing across ordered legs stays in the + * SERVICE (unchanged JS). + */ + async listOwnedFlightLegsForUser(userId: number): Promise<{ reservation_id: number; lat: number; lng: number }[]> { + return await this.kysely() + .selectFrom('reservation_endpoints as re') + .innerJoin('reservations as r', 'r.id', 're.reservation_id') + .innerJoin('trips as t', 't.id', 'r.trip_id') + .leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', userId)) + .select(['re.reservation_id', 're.lat', 're.lng']) + .where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', 'is not', null)])) + .where('r.type', '=', 'flight') + .where('r.status', '!=', 'cancelled') + .where((eb) => travelerOwnsExpr(eb, userId)) + .orderBy('re.reservation_id', 'asc') + .orderBy('re.sequence', 'asc') + .execute(); + } + + /** + * RPL6 (`RoadtripPlanService.withTerminals`) — `SELECT e.reservation_id, + * e.role, e.sequence, e.name, e.code, e.lat, e.lng FROM + * reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + * WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`. Same join and + * order as {@link listForTrip} (RS3) but its own column list, per D4. + */ + async listRoadtripTerminals(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('e') + .join('e.reservation', 'r') + .select([columnRef(platform, 'e.reservation_id').as('reservation_id'), 'e.role', 'e.sequence', 'e.name', 'e.code', 'e.lat', 'e.lng']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'e.reservation': 'asc', 'e.sequence': 'asc' }) + .execute('all', false); + } +} + +/** RPL6's row: a terminal of a booking the road trip's seam reads, keyed by its reservation. */ +export interface RoadtripTerminalRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; +} + +/** AT6/AT45's shared projection — `EndpointRow`'s own shape in `atlas.service.ts` (kept independent here so this repository doesn't import a `nest/` type). */ +export interface TravelerOwnedEndpointRow { + id: number; + reservation_id: number; + trip_id: number; + role: string; + code: string | null; + lat: number; + lng: number; + local_date: string | null; + local_time: string | null; + reservation_type: string | null; + reservation_status: string | null; + fallback_time: string | null; +} + +/** {@link ReservationEndpointsRepository.listOwnedEndpointsForTrips}'s narrow `reservation_endpoints`/`reservations`/`reservation_travelers` shape (`r` aliased for `travelerOwnsExpr`, R7). */ +interface TravelerOwnedEndpointsKyselyDB extends ReservationTravelersOwnsKyselyDB { + reservation_endpoints: { id: number; reservation_id: number; role: string; sequence: number; code: string | null; lat: number; lng: number; local_date: string | null; local_time: string | null }; + reservations: ReservationTravelersOwnsKyselyDB['reservations'] & { trip_id: number; type: string | null; status: string | null; reservation_time: string | null; reservation_end_time: string | null }; +} + +/** {@link ReservationEndpointsRepository.listOwnedEndpointsForUser}/{@link ReservationEndpointsRepository.listOwnedFlightLegsForUser}'s narrow shape, adding `trips`/`trip_members` to {@link TravelerOwnedEndpointsKyselyDB}. */ +interface TravelerOwnedEndpointsWithTripsKyselyDB extends TravelerOwnedEndpointsKyselyDB { + trips: { id: number; user_id: number; start_date: string | null; end_date: string | null }; + trip_members: { trip_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/ReservationTravelers.repository.ts b/server/src/db/repositories/ReservationTravelers.repository.ts new file mode 100644 index 000000000..c72e39de7 --- /dev/null +++ b/server/src/db/repositories/ReservationTravelers.repository.ts @@ -0,0 +1,77 @@ +import type { ReservationTravelers } from '../entities/ReservationTravelers.entity'; +import { coalesce, columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A traveler row as RS6/RR3 project it (before `toTraveler()`'s `avatar_url` add). */ +export interface TravelerJoinRow { + reservation_id: number; + user_id: number; + username: string; + avatar: string | null; + is_guest: number | null; +} + +export class ReservationTravelersRepository extends TrekRepository { + /** + * RS6 (`ReservationsService.loadTravelersByTrip`) — `SELECT + * rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS + * username, u.avatar, u.is_guest FROM reservation_travelers rt JOIN + * reservations r ON rt.reservation_id = r.id JOIN users u ON rt.user_id = + * u.id WHERE r.trip_id = ? ORDER BY rt.reservation_id`. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + // `rt.reservation_id`/`rt.user_id` are `persist(false)` mirrors of the + // `reservation`/`user` relations, both ALSO actively joined here + // (aliased `r`/`u`) — the exact trap `DayAssignmentsRepository + // .assignmentWithPlaceSelect`'s `category_id` docstring documents + // (a bare select silently aliases off the joined entity instead); + // `columnRef` selects the literal physical columns. + return this.qb('rt') + .join('rt.reservation', 'r') + .join('rt.user', 'u') + .select([columnRef(platform, 'rt.reservation_id').as('reservation_id'), columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'rt.reservation': 'asc' }) + .execute('all', false); + } + + /** + * RR3 (`ReservationsReadService.loadTravelers`) — `SELECT rt.user_id, + * COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest + * FROM reservation_travelers rt JOIN users u ON rt.user_id = u.id WHERE + * rt.reservation_id = ?` — **no ORDER BY** (unlike `listForTrip`), matching + * the legacy statement's own row order (SQLite's). + */ + async listForReservation(reservation_id: number): Promise[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('rt') + .join('rt.user', 'u') + .select([columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest']) + .where({ reservation: reservation_id }) + .execute[]>('all', false); + } + + /** RS8 (`ReservationsService.setReservationTravelers`'s replace-all) — `DELETE FROM reservation_travelers WHERE reservation_id = ?`. */ + async deleteForReservation(reservation_id: number): Promise { + await this.nativeDelete({ reservation: reservation_id }); + } + + /** + * RS9 — `INSERT OR IGNORE INTO reservation_travelers (reservation_id, + * user_id) VALUES (?, ?)`, run once per id. `upsertMany` with + * `onConflictAction: 'ignore'` on the table's real unique key + * (`UNIQUE(reservation_id, user_id)`, `ReservationTravelers.entity.ts`'s + * `uniques`), named by entity property (`reservation`/`user`, the + * `AssignmentParticipantsRepository.insertIgnore` precedent). The caller + * roster-scopes and dedupes the ids before calling; the empty-array guard + * here is defensive, matching the service's own guard. + */ + async insertIgnore(reservation_id: number, user_ids: number[]): Promise { + if (user_ids.length === 0) return; + await this.upsertMany( + user_ids.map((user_id) => ({ reservation: reservation_id, user: user_id })), + { onConflictFields: ['reservation', 'user'], onConflictAction: 'ignore' }, + ); + } +} diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts new file mode 100644 index 000000000..96c8781fd --- /dev/null +++ b/server/src/db/repositories/Reservations.repository.ts @@ -0,0 +1,1756 @@ +import type { Reservations } from '../entities/Reservations.entity'; +import { DayAssignments } from '../entities/DayAssignments.entity'; +import { Days } from '../entities/Days.entity'; +import { castIntegerKysely, coalesceOverride, columnRef, concatKysely, dayDistance, startsWithIsoDateKysely, substringKysely } from '../dialect/sql-functions'; +import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyselyDB } from './_shared/reservation-visibility'; +import type { DayAssignmentRow } from './DayAssignments.repository'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as + * place_name, r.assignment_id, ap.place_id as accommodation_place_id, + * acc_p.name as accommodation_name, ap.start_day_id as + * accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id FROM + * reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON + * r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id = + * ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service + * folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`, + * `endpoints` and `travelers` on afterward (unchanged from the legacy split). + */ +export interface ReservationJoinRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; + day_number: number | null; + place_name: string | null; + accommodation_place_id: number | null; + accommodation_name: string | null; + accommodation_start_day_id: number | null; + accommodation_end_day_id: number | null; +} + +/** + * Kysely typing for the RS18/RR1 join (`joinedQuery` below) — `reservations + * .accommodation_id` is a bare `p.text().nullable()` column with no FK to + * `day_accommodations` (§18.1 of the inventory), so the QueryBuilder's + * relation-path `.join()` cannot express `LEFT JOIN day_accommodations ap ON + * r.accommodation_id = ap.id` at all — the same escape hatch + * `DayAssignmentsRepository.effectiveStart`/`TripsRepository.tripSelectQuery` + * use. `id`/`trip_id` stay `number | string` (raw-bind, D4's T5 seam): a + * Kysely `.where(col, '=', value)` call is a TYPED condition regardless of + * how loosely its value is typed (program rule 23 bans a raw SQL-text + * condition string, not a loosely-typed bound value), so this join keeps the + * legacy statement's exact raw-bind flexibility. + */ +interface ReservationJoinKyselyDB { + reservations: { + id: number | string; + trip_id: number | string; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; + }; + days: { id: number; day_number: number }; + places: { id: number; name: string }; + day_accommodations: { id: number; place_id: number | null; start_day_id: number; end_day_id: number }; +} + +/** + * Kysely typing for `restampLinkedReservation` (DY23). `accommodation_id: + * string | number | null` — R2's ruling: the legacy statement binds `accId` + * (a plain JS `number`, `stay.id`) straight into the `:accId` named param + * compared against the TEXT column, so this method's caller passes the SAME + * number, unconverted, and the interface is widened only so that bind + * type-checks (SQLite applies its own TEXT-affinity conversion at compare + * time regardless of which JS type bound the value — see the method's own + * docstring). + */ +interface ReservationRestampKyselyDB { + reservations: { + id: number; + accommodation_id: string | number | null; + type: string | null; + day_id: number | null; + reservation_time: string | null; + }; +} + +/** + * Kysely typing for AC37/AC40 (`listIdMetadataByStay`/`listIdsByStay`). + * + * **Revert to the REAL-bound compare (Plan 3d Task 7 whole-plan review, M3; + * corrects this docstring's earlier claim).** A bound-parameter `WHERE + * accommodation_id = ?` with a plain JS `number` is EXACTLY the comparison + * the legacy statement's own `Number(id)` bind produced: `better-sqlite3` + * binds every plain JS number as SQLite REAL, integer-valued or not + * (`typeof(?)` on a bound `14` reads back `'real'`) — including through + * MikroORM's Kysely dialect — and SQLite's TEXT-affinity conversion of a + * REAL for a comparison renders its decimal form (`14` → `'14.0'`), so a + * bound `WHERE accommodation_id = ?` matches `'14.0'` ONLY, same as the + * legacy statement — never `'14'`. The earlier version of this docstring + * claimed the opposite (that `em.insert()`/`nativeUpdate()` calls in this + * cluster store the bare `'14'` shape and a REAL-bound compare would + * therefore miss them): that was true only because RS28/RS41 + * (`reservations.service.ts`, outside this repository) used to write + * `String(n)` instead of the legacy's REAL-bound shape (H1, the same + * review). H1 fixes every write in this cluster to store `'.0'` + * (`legacyBoundIntegerText`), so the widening below has no remaining + * reason to exist, and parity is law once it doesn't: `CAST(accommodation_id + * AS INTEGER) = ?` (via {@link castIntegerKysely}) matches `'14'`, `'14.0'`, + * `'14 '` and `'14abc'` alike — a WIDER row set than the legacy's own + * `'14.0'`-only compare (measured, task-7-review.md M3) — which is an + * unpinned parity break, not a fix, on a read the legacy also bound + * `Number(id)` for. `castIntegerKysely` stays in use elsewhere in this file + * (RS20/RV2's correlated subqueries) where the legacy statement's OWN + * comparison used a `CAST`, not a bound number — this interface's two + * consumers are not that case. + */ +interface ReservationsByAccommodationKyselyDB { + reservations: { + id: number; + // `string | number | null`, not just `string` (R2's ruling, the same + // widening {@link ReservationRestampKyselyDB} documents for DY23): the + // column is TEXT, but the bind below is a plain `number`, matching the + // legacy's own `Number(id)` bind — the interface only needs to + // type-check that bind, not describe the column's storage type. + accommodation_id: string | number | null; + metadata: string | null; + }; +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 4 (calendar, `listUpcoming`, the visibility-predicate +// consumers incl. `share`, the `public-api` pickup) — additive. `calendar/**` +// and `share/**`'s reads on `day_accommodations`/`reservation_endpoints`/ +// `reservation_day_positions` land here rather than on those tables' own +// repositories: `RoadtripVias`/`RoadtripDayTracks`/`RoadtripPreferences`/ +// `RoadtripDayBoundaries`/`DayAccommodations`/`ReservationEndpoints`/ +// `ReservationTravelers` repositories are Task 6's file set for this window +// (trip copy's additive methods), so a cross-table Kysely read — the SAME +// escape hatch `joinedQuery()`/`getAssignmentTripId`/`findNearestDayId` +// above already use to reach `days`/`places`/`day_accommodations` from this +// file — lands the read here instead of opening a second implementer's file. +// `DayAssignments.repository.ts` is not in Task 6's set, but CL4's read is +// kept alongside its RS20/CL2/CL7 siblings for the same "one file, one +// diff" reason. +// --------------------------------------------------------------------------- + +/** + * CL2 (`CalendarService.buildTripCalendar`'s reservation read) — `SELECT + * r.*, pl.lat AS place_lat, pl.lng AS place_lng, sd.date AS stay_start_date, + * ed.date AS stay_end_date, a.check_in AS stay_check_in, a.check_out AS + * stay_check_out, (SELECT MIN(r2.id) FROM reservations r2 WHERE + * r2.accommodation_id = a.id) AS stay_first_reservation_id, rd.date AS + * day_date, red.date AS end_day_date FROM reservations r LEFT JOIN places + * pl … LEFT JOIN day_accommodations a ON r.accommodation_id = a.id LEFT + * JOIN days sd/ed/rd/red … WHERE r.trip_id = ? AND `, no `ORDER + * BY`. `r.accommodation_id = a.id` and `r2.accommodation_id = a.id` are + * plain column-to-column joins (no `CAST`) — SQLite applies numeric + * affinity to the TEXT side against a genuine INTEGER column on its own + * (§18.1), the same reasoning `joinedQuery()`'s `ap`/`acc_p` joins above + * already rely on; a `CAST` is only needed where one side is a BOUND value + * (RV2, RS20's correlated title subquery). The `stay_first_reservation_id` + * subquery is deliberately **not** filtered by RV1 — inventory §18.2's + * documented, unfixed hole (a staged sibling booking with a lower id can + * win the slot and suppress a public booking's all-day block); kept + * byte-for-byte, pinned by CAL-CL2-HOLE-001 in the repository test. + */ +export interface CalendarReservationRow extends ReservationAllColumnsRow { + place_lat: number | null; + place_lng: number | null; + stay_start_date: string | null; + stay_end_date: string | null; + stay_check_in: string | null; + stay_check_out: string | null; + stay_first_reservation_id: number | null; + day_date: string | null; + end_day_date: string | null; +} + +interface CalendarReservationKyselyDB { + reservations: ReservationJoinKyselyDB['reservations']; + places: { id: number; lat: number | null; lng: number | null }; + day_accommodations: { id: number; start_day_id: number | null; end_day_id: number | null; check_in: string | null; check_out: string | null }; + days: { id: number; date: string | null }; +} + +/** + * CL4 (`CalendarService.buildTripCalendar`'s per-day assignment read, + * previously run once per dated day) — `SELECT da.*, p.name as place_name, + * p.address as place_address, p.lat as place_lat, p.lng as place_lng, + * COALESCE(da.assignment_time, p.place_time) as effective_time, + * COALESCE(da.assignment_end_time, p.end_time) as effective_end_time FROM + * day_assignments da JOIN places p ON da.place_id = p.id WHERE da.day_id = + * ? AND da.accommodation_id IS NULL ORDER BY da.order_index ASC, + * da.created_at ASC` — the booked-night stop (its own accommodation-linked + * `day_assignments` row) is excluded, matching the legacy comment ("that + * stop is the booking … reading it here as well would put the hotel on the + * day a second time"). Kept per-day (the caller's own loop), not batched — + * result-identical either way per the inventory's own note; per-day is the + * smaller diff against the legacy statement shape. + */ +export interface CalendarStopRow extends DayAssignmentRow { + place_name: string; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; + effective_time: string | null; + effective_end_time: string | null; +} + +interface CalendarStopsKyselyDB { + day_assignments: { + id: number; day_id: number; place_id: number; order_index: number | null; notes: string | null; + reservation_status: string | null; reservation_notes: string | null; reservation_datetime: string | null; + created_at: string | null; assignment_time: string | null; assignment_end_time: string | null; + leg_transport_mode: string | null; incoming_leg_transport_mode: string | null; end_day: number; accommodation_id: number | null; + }; + places: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; place_time: string | null; end_time: string | null }; +} + +/** + * CL7 (`CalendarService.buildTripCalendar`'s check-in/check-out stay read) + * — `SELECT a.id, a.check_in, a.check_in_end, a.check_out, sd.date AS + * start_date, ed.date AS end_date, p.name AS place_name, p.address AS + * place_address, p.lat AS place_lat, p.lng AS place_lng, (SELECT r.title + * FROM reservations r WHERE r.accommodation_id = a.id AND ORDER + * BY r.id ASC LIMIT 1) AS reservation_title FROM day_accommodations a LEFT + * JOIN days sd … LEFT JOIN days ed … LEFT JOIN places p … WHERE a.trip_id = + * ? AND ORDER BY a.id ASC`. The `reservation_title` subquery is + * a plain column-to-column compare too (no `CAST`), same reasoning as CL2. + * `.where('a.trip_id', ...).where(publicStayExists)` are chained BEFORE the + * `sd`/`ed`/`p` joins below (Kysely renders WHERE after JOIN regardless of + * call order — the AST, not the call sequence, decides clause order) so + * `publicStayExists`'s narrow `ExpressionBuilder<…, 'a'>` signature sees + * exactly the `a` alias it requires (Task 0 review's own finding: a + * differently-joined outer query typechecks as long as the alias stays + * `a`). + */ +export interface CalendarStayRow { + id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + start_date: string | null; + end_date: string | null; + place_name: string | null; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; + reservation_title: string | null; +} + +interface CalendarStayKyselyDB extends ReservationVisibilityKyselyDB { + day_accommodations: { + id: number; trip_id: number | string; place_id: number | null; start_day_id: number | null; end_day_id: number | null; + check_in: string | null; check_in_end: string | null; check_out: string | null; + }; + reservations: ReservationVisibilityKyselyDB['reservations'] & { title: string }; + days: { id: number; date: string | null }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; +} + +/** + * RS20 (`ReservationsService.listUpcoming`) — the CTE + `UNION ALL` + * statement, inventory §2e. Ruling (ii): ONE fully typed Kysely statement + * (no `connection.execute()` exception, no JS merge/re-sort — the reviewer's + * prototype matched legacy rows across 5 `(today, now, limit)` settings + * incl. the cross-table `id` tiebreak, and the plan adopted that). GLOB + * through `startsWithIsoDateKysely`; `CAST` through `castIntegerKysely` + * (the correlated title subquery compares a BOUND `a.id` — an outer-query + * value, not a plain column-to-column join, unlike CL2/CL7's `MIN`/title + * subqueries — so the legacy statement casts here too: + * `CAST(res.accommodation_id AS INTEGER) = a.id`); `||` through + * `concatKysely`; `substr` through `substringKysely`. `LIMIT` stays inside + * SQL; the SQLite collation `ORDER BY at_date ASC, COALESCE(at_time, + * '00:00') ASC, id ASC` (the cross-table `id` tiebreak — a stay id and a + * reservation id can collide) is reproduced exactly, never a JS `.sort()`. + * + * Each of the three `entries` arms is `$castTo()`'d before the + * `unionAll` chain: Kysely infers a literal type (`'checkin'`) for + * `eb.val('checkin').as('type')` in arms 2/3 against `r.type: string | + * null` in arm 1, and the three arms' `title`/`reservation_time` sources + * differ in nullability shape too — the union of literal/nullable variants + * doesn't collapse into one column type on its own, the same + * `DayStopRow`/`AssignmentTimeSortKyselyDB` precedent + * (`DayAssignments.repository.ts::listForTimeSort`) uses for its own + * `eb.and([...])`-inferred boolean column. + */ +export interface UpcomingReservationRow { + id: number; + trip_id: number; + title: string; + type: string | null; + status: string | null; + location: string | null; + reservation_time: string | null; + confirmation_number: string | null; + trip_title: string; + trip_cover: string | null; + day_date: string | null; + place_name: string | null; + place_image: string | null; +} + +/** RS20's `entries` CTE row shape — see {@link UpcomingReservationRow}'s docstring for why every arm is cast to this. */ +interface EntriesRow extends UpcomingReservationRow { + at_date: string | null; + at_time: string | null; +} + +interface UpcomingReservationsKyselyDB { + trips: { id: number; user_id: number; title: string; cover_image: string | null; is_archived: number }; + trip_members: { trip_id: number; user_id: number }; + reservations: { + id: number; trip_id: number; title: string; type: string | null; status: string | null; + location: string | null; reservation_time: string | null; confirmation_number: string | null; + day_id: number | null; place_id: number | null; accommodation_id: string | null; + }; + days: { id: number; date: string | null }; + places: { id: number; name: string | null; image_url: string | null }; + day_accommodations: { + id: number; trip_id: number; place_id: number | null; start_day_id: number; end_day_id: number; + check_in: string | null; check_out: string | null; confirmation: string | null; + }; +} + +/** + * `share.service.ts:251` (`publicEndpointsByReservation`) — `SELECT + * e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, + * e.timezone, e.local_date, e.local_time FROM reservation_endpoints e JOIN + * reservations r ON r.id = e.reservation_id WHERE r.trip_id = ? ORDER BY + * e.reservation_id ASC, e.sequence ASC`. Not filtered by RV1 — the legacy + * statement loads every reservation's endpoints regardless of + * `ingest_state`, and the caller only ever looks one up for a reservation + * `share.service.ts`'s OWN filtered read already returned (parity, matching + * `CalendarService.loadEndpointsByTrip`'s identical shape, §4's CL6 note). + */ +export interface ShareEndpointRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_date: string | null; + local_time: string | null; +} + +interface ShareEndpointsKyselyDB { + reservation_endpoints: { + reservation_id: number; role: string; sequence: number; name: string; code: string | null; + lat: number; lng: number; timezone: string | null; local_date: string | null; local_time: string | null; + }; + reservations: { id: number; trip_id: number | string }; +} + +/** + * `share.service.ts:368` (`getSharedTripData`'s day-position read) — + * `SELECT rdp.reservation_id, rdp.day_id, rdp.position FROM + * reservation_day_positions rdp JOIN reservations r ON rdp.reservation_id = + * r.id WHERE r.trip_id = ?`, no `ORDER BY`, no RV1 filter (same reasoning + * as the endpoints read above — the caller only consults `posMap` for a + * reservation its own filtered read already returned). + */ +export interface ShareDayPositionRow { + reservation_id: number; + day_id: number; + position: number; +} + +interface ShareDayPositionsKyselyDB { + reservation_day_positions: { reservation_id: number; day_id: number; position: number }; + reservations: { id: number; trip_id: number | string }; +} + +/** + * `share.service.ts:387` (`getSharedTripData`'s public booking read) — + * `SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r WHERE r.trip_id + * = ? AND ORDER BY r.reservation_time ASC` — + * `PUBLIC_RESERVATION_COLUMNS` is `id, trip_id, day_id, end_day_id, + * place_id, accommodation_id, title, type, status, location, + * reservation_time, reservation_end_time, notes, url, metadata, + * created_at`, the allow-list a public link may see (never the + * confirmation number, the import trail or who is travelling). + */ +export interface SharePublicReservationRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + accommodation_id: string | null; + title: string; + type: string | null; + status: string | null; + location: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + notes: string | null; + url: string | null; + metadata: string | null; + created_at: string | null; +} + +interface SharePublicReservationKyselyDB { + reservations: ReservationVisibilityKyselyDB['reservations'] & { + trip_id: number | string; + day_id: number | null; end_day_id: number | null; place_id: number | null; title: string; + type: string | null; status: string | null; location: string | null; + reservation_time: string | null; reservation_end_time: string | null; + notes: string | null; url: string | null; metadata: string | null; created_at: string | null; + }; +} + +/** + * `share.service.ts:401` (`getSharedTripData`'s public stay read) — + * `SELECT ${PUBLIC_ACCOMMODATION_COLUMNS}, p.name as place_name, p.address + * as place_address, p.lat as place_lat, p.lng as place_lng FROM + * day_accommodations a JOIN places p ON a.place_id = p.id WHERE a.trip_id = + * ? AND ` — an INNER `JOIN`, not `LEFT`, unlike CL7: a stay with + * no linked place is silently excluded from a public link (parity, kept + * exactly). `PUBLIC_ACCOMMODATION_COLUMNS` is `id, trip_id, place_id, + * start_day_id, end_day_id, check_in, check_in_end, check_out, notes`. + */ +export interface SharePublicAccommodationRow { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number | null; + end_day_id: number | null; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + notes: string | null; + place_name: string | null; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; +} + +interface SharePublicAccommodationKyselyDB extends ReservationVisibilityKyselyDB { + day_accommodations: { + id: number; trip_id: number | string; place_id: number | null; start_day_id: number | null; end_day_id: number | null; + check_in: string | null; check_in_end: string | null; check_out: string | null; notes: string | null; + }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; +} + +/** + * `public-api.service.ts::reservationsByDay` (Task 5's `// Task 2` pickup) + * — `SELECT day_id, type, title, location, reservation_time, + * reservation_end_time, status, notes FROM reservations WHERE trip_id = ? + * AND day_id IS NOT NULL ORDER BY day_id ASC, reservation_time ASC`. + */ +export interface PublicApiScheduledReservationRow { + day_id: number; + type: string | null; + title: string | null; + location: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + status: string | null; + notes: string | null; +} + +/** + * `public-api.service.ts::buildUnscheduledReservations` (Task 5's `// Task + * 2` pickup) — the same 7 columns minus `day_id`, `WHERE trip_id = ? AND + * day_id IS NULL ORDER BY reservation_time ASC, id ASC`. + */ +export type PublicApiUnscheduledReservationRow = Omit; + +interface PublicApiReservationKyselyDB { + reservations: { + id: number; trip_id: number; day_id: number | null; type: string | null; title: string | null; + location: string | null; reservation_time: string | null; reservation_end_time: string | null; + status: string | null; notes: string | null; + }; +} + +/** + * `public-api.service.ts::buildAccommodations` (Task 5's `// Task 3` + * pickup) — `SELECT p.name, p.address, p.lat, p.lng, ds.date AS start_date, + * de.date AS end_date, a.check_in, a.check_out, a.notes FROM + * day_accommodations a LEFT JOIN places p ON p.id = a.place_id LEFT JOIN + * days ds ON ds.id = a.start_day_id LEFT JOIN days de ON de.id = + * a.end_day_id WHERE a.trip_id = ? ORDER BY ds.date ASC`. + */ +export interface PublicApiAccommodationRow { + name: string | null; + address: string | null; + lat: number | null; + lng: number | null; + start_date: string | null; + end_date: string | null; + check_in: string | null; + check_out: string | null; + notes: string | null; +} + +interface PublicApiAccommodationKyselyDB { + day_accommodations: { id: number; trip_id: number; place_id: number | null; start_day_id: number | null; end_day_id: number | null; check_in: string | null; check_out: string | null; notes: string | null }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; + days: { id: number; date: string | null }; +} + +/** + * `public-api.service.ts::buildUnplannedPlaces` (Task 5's `// Task 3` + * pickup — "the whole statement stays raw rather than splitting the + * places/day_assignments reads from the one 3d-table predicate", the + * method's own comment) — `SELECT p.name, p.address, p.lat, p.lng, + * p.place_time, p.end_time, p.duration_minutes, p.notes, p.transport_mode, + * c.name AS category FROM places p LEFT JOIN categories c ON c.id = + * p.category_id WHERE p.trip_id = ? AND NOT EXISTS (SELECT 1 FROM + * day_assignments da WHERE da.place_id = p.id) AND NOT EXISTS (SELECT 1 + * FROM day_accommodations a WHERE a.place_id = p.id) ORDER BY + * p.created_at ASC, p.id ASC`. + */ +export interface PublicApiUnplannedPlaceRow { + name: string; + address: string | null; + lat: number | null; + lng: number | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + notes: string | null; + transport_mode: string | null; + category: string | null; +} + +interface PublicApiUnplannedPlaceKyselyDB { + places: { + id: number; trip_id: number; name: string; address: string | null; lat: number | null; lng: number | null; + place_time: string | null; end_time: string | null; duration_minutes: number | null; notes: string | null; + transport_mode: string | null; category_id: number | null; created_at: string | null; + }; + categories: { id: number; name: string }; + day_assignments: { id: number; place_id: number }; + day_accommodations: { id: number; place_id: number | null }; +} + +/** + * Plan 3h Task 4 (`AirportsService.backfillFlightEndpoints`, AIR1) — + * additive. The narrow `reservations`/`reservation_endpoints` shape the + * `NOT EXISTS` guard needs. + */ +interface FlightsMissingEndpointsKyselyDB { + reservations: { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null; type: string | null }; + reservation_endpoints: { id: number; reservation_id: number }; +} + +export class ReservationsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('reservations as r') + .leftJoin('days as d', 'd.id', 'r.day_id') + .leftJoin('places as p', 'p.id', 'r.place_id') + .leftJoin('day_accommodations as ap', 'ap.id', 'r.accommodation_id') + .leftJoin('places as acc_p', 'acc_p.id', 'ap.place_id') + .selectAll('r') + .select([ + 'd.day_number as day_number', + 'p.name as place_name', + 'ap.place_id as accommodation_place_id', + 'acc_p.name as accommodation_name', + 'ap.start_day_id as accommodation_start_day_id', + 'ap.end_day_id as accommodation_end_day_id', + ]); + } + + /** + * RS18 (`ReservationsService.list`) — the joined projection for a whole + * trip, `ORDER BY r.reservation_time ASC, r.created_at ASC`. + */ + async listForTrip(trip_id: number | string): Promise { + const rows = await this.joinedQuery() + .where('r.trip_id', '=', trip_id) + .orderBy('r.reservation_time', 'asc') + .orderBy('r.created_at', 'asc') + .execute(); + return rows as ReservationJoinRow[]; + } + + /** + * RR1 (`ReservationsReadService.getReservationWithJoins`) — the SAME + * projection for one reservation, no `ORDER BY` (a single row). + */ + async findWithJoins(id: number | string): Promise { + const row = await this.joinedQuery().where('r.id', '=', id).executeTakeFirst(); + return row as ReservationJoinRow | undefined; + } + + /** + * RS35 (`ReservationsService.getReservation`) — `SELECT * FROM + * reservations WHERE id = ? AND trip_id = ?`, the trip-scoping guard every + * write path re-reads through. `id`/`trip_id: number` (rule 21 — the + * SERVICE parses both ONCE via `toRowId` before calling this, and every + * later write in the same method reuses that same parsed value, never a + * second, independent conversion). + */ + async findInTrip(id: number, trip_id: number): Promise { + return this.qb('r') + .select(['r.*']) + .where({ id, trip: trip_id }) + .execute('get', false); + } + + /** RS45 (`ReservationsService.remove`'s pre-image read) — `SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?`. */ + async findHeaderInTrip(id: number, trip_id: number): Promise<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined> { + return this.qb('r') + .select(['r.id', 'r.title', 'r.type', 'r.accommodation_id']) + .where({ id, trip: trip_id }) + .execute<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined>('get', false); + } + + /** + * RS12 (`ReservationsService.resyncReservationDays`) — `SELECT id, + * reservation_time, reservation_end_time, day_id, end_day_id FROM + * reservations WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id + * IS NULL) AND reservation_time IS NOT NULL`. + */ + async listResyncCandidates(trip_id: number): Promise<{ + id: number; reservation_time: string | null; reservation_end_time: string | null; + day_id: number | null; end_day_id: number | null; + }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', 'r.reservation_time', 'r.reservation_end_time', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id')]) + .where({ + trip: trip_id, + $or: [{ type: { $ne: 'hotel' } }, { accommodation_id: null }], + reservation_time: { $ne: null }, + }) + .execute<{ id: number; reservation_time: string | null; reservation_end_time: string | null; day_id: number | null; end_day_id: number | null }[]>('all', false); + } + + /** RS13 (`resyncReservationDays`'s per-row write) — `UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?`. */ + async setDays(id: number, day_id: number | null, end_day_id: number | null): Promise { + await this.nativeUpdate({ id }, { day: day_id, endDay: end_day_id }); + } + + /** + * RS28 (`ReservationsService.createInTx`) — `INSERT INTO reservations + * (trip_id, day_id, end_day_id, place_id, assignment_id, title, + * reservation_time, reservation_end_time, location, confirmation_number, + * notes, url, status, type, accommodation_id, metadata, needs_review) + * VALUES (?×17)`. `sync_enabled`/`ingest_state`/`day_plan_position` + * deliberately NOT named, so the column's own DB-level `DEFAULT` applies + * (`sync_enabled INTEGER DEFAULT 1`, `Migration20200101021800_…`; + * `ingest_state` `.default('live')`) — the caller has already resolved + * every value (the `|| null` coercions, `resolvedOrNull`, the numeric + * `accommodation_id` stringified for the TEXT column, R2). + */ + async insertReservation(input: { + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + status: string; + type: string; + accommodation_id: string | null; + metadata: string | null; + needs_review: number; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + endDay: input.end_day_id, + place: input.place_id, + assignment: input.assignment_id, + title: input.title, + reservation_time: input.reservation_time, + reservation_end_time: input.reservation_end_time, + location: input.location, + confirmation_number: input.confirmation_number, + notes: input.notes, + url: input.url, + status: input.status, + type: input.type, + accommodation_id: input.accommodation_id, + metadata: input.metadata, + needs_review: input.needs_review, + }); + } + + /** + * RS41 (`ReservationsService.updateInTx`) — the 16-column `UPDATE + * reservations SET … WHERE id = ?`. The `PlacesRepository.updatePlace`/PL11 + * precedent: the four legacy `COALESCE(?, col)` keep-if-null columns + * (`title`, `status`, `type`, `needs_review`) are resolved to their FINAL + * value by the SERVICE first (it already holds the pre-image via the + * `current` parameter every caller passes), so this writes exactly what it + * is handed — no SQL-side COALESCE. `id: number` (rule 21 — the SAME + * `toRowId`-parsed value `findInTrip`'s gate read used). + */ + async updateReservation(id: number, write: { + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + status: string; + type: string; + accommodation_id: string | null; + metadata: string | null; + needs_review: number; + }): Promise { + await this.nativeUpdate({ id }, { + title: write.title, + reservation_time: write.reservation_time, + reservation_end_time: write.reservation_end_time, + location: write.location, + confirmation_number: write.confirmation_number, + notes: write.notes, + url: write.url, + day: write.day_id, + endDay: write.end_day_id, + place: write.place_id, + assignment: write.assignment_id, + status: write.status, + type: write.type, + accommodation_id: write.accommodation_id, + metadata: write.metadata, + needs_review: write.needs_review, + }); + } + + /** + * RS33 (`updatePositions`'s legacy/global branch) — `UPDATE reservations + * SET day_plan_position = ? WHERE id = ? AND trip_id = ?`. `position: + * number | null`, never `undefined` (R8 — the SERVICE maps an absent + * `day_plan_position` to `null` explicitly before calling, so + * `nativeUpdate` always WRITES the column instead of silently skipping it). + */ + async setDayPlanPosition(id: number, trip_id: number, position: number | null): Promise { + await this.nativeUpdate({ id, trip: trip_id }, { day_plan_position: position }); + } + + /** RS50 (`remove`) — `DELETE FROM reservations WHERE id = ?`, unscoped (the caller already proved trip access via `findHeaderInTrip`). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** DY14 (`DaysService.restampReservationDates`) — `SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?`. */ + async listForRestamp(trip_id: number): Promise<{ + id: number; day_id: number | null; end_day_id: number | null; + reservation_time: string | null; reservation_end_time: string | null; + }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id'), 'r.reservation_time', 'r.reservation_end_time']) + .where({ trip: trip_id }) + .execute<{ id: number; day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null }[]>('all', false); + } + + /** DY15 — `UPDATE reservations SET reservation_time = ? WHERE id = ?`. */ + async setReservationTime(id: number, reservation_time: string): Promise { + await this.nativeUpdate({ id }, { reservation_time }); + } + + /** DY16 — `UPDATE reservations SET reservation_end_time = ? WHERE id = ?`. */ + async setReservationEndTime(id: number, reservation_end_time: string): Promise { + await this.nativeUpdate({ id }, { reservation_end_time }); + } + + /** + * DY23 (`DaysService.resyncAccommodationDays`) — the named-param `UPDATE + * reservations SET day_id = :dayId, reservation_time = CASE WHEN + * reservation_time IS NULL THEN :date ELSE :date || SUBSTR(reservation_time, + * 11) END WHERE accommodation_id = :accId AND type = 'hotel'`. Kysely (the + * `CASE WHEN … ELSE … || SUBSTR(...)` shape has no typed-filter + * equivalent — the T6 tier per the inventory's own ruling), through + * `concatKysely`/`substringKysely` (`sql-functions.ts`'s Kysely-expression + * twins of `concat`/`substring` — the MikroORM `RawQueryFragment` forms + * render as a literal `?` token inside a Kysely statement and throw at + * execution, Task 0 review H1). `accommodation_id` (TEXT) compared against + * `accId` (a bound NUMBER, exactly as the legacy statement bound it — R2, + * never stringified here, so the SAME SQLite TEXT-affinity conversion the + * legacy statement triggered still applies, including its own miss on a + * `"14.0"`-shaped row — parity, not a fix). + */ + async restampLinkedReservation(accommodation_id: number, day_id: number, date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .updateTable('reservations') + .set((eb) => ({ + day_id, + reservation_time: eb + .case() + .when('reservation_time', 'is', null) + .then(eb.val(date)) + .else(concatKysely(platform, eb, { value: date }, { expression: substringKysely(platform, eb, 'reservation_time', 11) })) + .end(), + })) + .where('accommodation_id', '=', accommodation_id) + .where('type', '=', 'hotel') + .execute(); + } + + /** + * RS22's dispatch (`ReservationsService.referencesOutsideTrip`'s + * `assignment_id` branch) — `SELECT d.trip_id FROM day_assignments da JOIN + * days d ON da.day_id = d.id WHERE da.id = ?`. Reached through + * `DayAssignmentsRepository`'s own relation join (`da.day` — the same + * cross-repository-read shape `TripMembersRepository.rosterUserIds` + * already uses for a table this repository does not own), never a new + * method on that file + * (`DayAssignmentsRepository`/`DaysRepository` are Task 1's + * additive-method territory per the file-ownership split). + */ + async getAssignmentTripId(assignment_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const dayAssignments = this.getEntityManager().getRepository(DayAssignments); + const row = await dayAssignments + .qb('da') + .join('da.day', 'd') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id: assignment_id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * RS11 (`resolveDayIdFromTime`'s nearest-day fallback) — `SELECT id FROM + * days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, + * date ASC LIMIT 1`. `dayDistance`, through `DaysRepository`'s own QB (the + * same cross-repository-read shape as `getAssignmentTripId` above — + * `DaysRepository` stays Task 1's additive-method territory; no new + * method is added there). + */ + async findNearestDayId(trip_id: number, iso_date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const days = this.getEntityManager().getRepository(Days); + const row = await days + .qb('d') + .select(['d.id']) + .where({ trip: trip_id }) + .orderBy([{ [dayDistance(platform, 'd.date', iso_date)]: 'asc' }, { date: 'asc' }]) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return row?.id; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** + * AC33 (`AccommodationsService.createAccommodation`) — `INSERT INTO + * reservations (trip_id, day_id, title, reservation_time, location, + * confirmation_number, notes, status, type, accommodation_id, metadata) + * VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)` — the + * auto-created partner hotel booking. `status`/`type` literals, `location` + * always `null` (matching the legacy statement exactly — it never binds a + * value there). `accommodation_id: string` — R2: the caller stringifies + * the new stay's numeric id (`String(newId)`) the same way `insertReservation`'s + * own `accommodation_id` caller already does, so SQLite's TEXT-affinity + * storage is identical to the legacy raw bind of the bare number. + */ + async insertHotelPartner(input: { + trip_id: number | string; + day_id: number; + title: string; + reservation_time: string | null; + confirmation_number: string | null; + notes: string | null; + accommodation_id: string; + metadata: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + title: input.title, + reservation_time: input.reservation_time, + location: null, + confirmation_number: input.confirmation_number, + notes: input.notes, + status: 'confirmed', + type: 'hotel', + accommodation_id: input.accommodation_id, + metadata: input.metadata, + }); + } + + /** + * AC37 (`AccommodationsService.updateAccommodation`) — `SELECT id, + * metadata FROM reservations WHERE accommodation_id = ?`, every linked + * booking (no unique constraint on the column — more than one booking can + * point at the same stay). Bound as a plain `number`, matching the + * legacy's own `Number(id)` bind byte-for-byte — see + * {@link ReservationsByAccommodationKyselyDB}'s docstring (M3, reverted + * from the `castIntegerKysely` widening). + */ + async listIdMetadataByStay(accommodation_id: number): Promise<{ id: number; metadata: string | null }[]> { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['id', 'metadata']) + .where('accommodation_id', '=', accommodation_id) + .execute(); + return rows as { id: number; metadata: string | null }[]; + } + + /** + * AC38 (`AccommodationsService.updateAccommodation`) — `UPDATE + * reservations SET metadata = ?, confirmation_number = COALESCE(?, + * confirmation_number) WHERE id = ?`. Runs AFTER `updateAccommodation`'s + * own transaction commits (§18.6 — R5 class, flagged not fixed). + * `coalesceOverride` for the value-side COALESCE — the bound `?` FIRST, + * the column the fallback, so the new value wins + * (`DayAccommodationsRepository.patchConfirmation`'s precedent). + */ + async setMetadataAndConfirmation(id: number, metadata: string, confirmation: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ + metadata, + confirmation_number: coalesceOverride(platform, confirmation, 'confirmation_number'), + }) + .where({ id }) + .execute('run'); + } + + /** + * AC40 (`AccommodationsService.deleteAccommodation`) — `SELECT id FROM + * reservations WHERE accommodation_id = ?`, ALL linked bookings. Same + * REAL-bound compare as {@link listIdMetadataByStay} (M3). + */ + async listIdsByStay(accommodation_id: number): Promise<{ id: number }[]> { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['id']) + .where('accommodation_id', '=', accommodation_id) + .execute(); + return rows as { id: number }[]; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 6 (`trips.service.ts::copy`) — moved here from + // `ReservationEndpoints.repository.ts` in the Task 7 whole-plan review's + // fix wave (item 8/D4, "one repository per table"): Task 4 owned this file + // for the Task 6 tree window, so TP58/TP59 (the reservations copy + // read/write) landed on `ReservationEndpointsRepository` instead at the + // time — a pure move now that the window is closed, same statements, + // same parity proof (`Reservations.repository.test.ts`). Endpoints/ + // travelers/day-positions are never copied with a reservation (R7, + // `copy` never copied them — kept as-is on their own repositories). + // --------------------------------------------------------------------------- + + /** + * TP58 (`trips.service.ts::copy`'s reservations read) — `SELECT * FROM + * reservations WHERE trip_id = ?`, no `ORDER BY` (matching the legacy + * statement; the copy loop's own id-remap doesn't depend on read order). + * `ReservationAllColumnsRow` (RS35's own `SELECT *` shape) rather than a + * second, differently-named copy of the same fields. + */ + async listAllForTrip(trip_id: number): Promise { + return await this.kysely<{ reservations: ReservationAllColumnsRow }>() + .selectFrom('reservations') + .selectAll() + .where('trip_id', '=', trip_id) + .execute(); + } + + /** + * TP59 (`trips.service.ts::copy`'s reservation INSERT) — `INSERT INTO + * reservations (trip_id, day_id, end_day_id, place_id, assignment_id, + * accommodation_id, title, reservation_time, reservation_end_time, + * location, confirmation_number, notes, url, status, type, metadata, + * day_plan_position, needs_review, ingest_state) VALUES (?×19)`. The + * `external_*`/`sync_enabled`/`created_at` columns are deliberately + * omitted (the legacy statement's own column list never named them — a + * copy must not inherit the source's external sync identity). + * + * `this.insert(...)`, not `this.getEntityManager().insert(Reservations, + * …)`: this repository IS templated on `Reservations` (unlike + * `ReservationEndpointsRepository`, where this method used to live and + * needed the escape hatch to reach a table it wasn't templated on) — the + * ordinary `TrekRepository.insert` is the right call here. + * + * `accommodation_id` arrives ALREADY formatted as the legacy `'.0'` + * TEXT shape (`row-id.ts`'s `legacyBoundIntegerText`, the program's + * Plan 3d Task 3 `"14.0"` finding): a plain JS number written here would + * be inlined as a SQL literal (rule 22) and stored as `'14'`, not the + * shape the legacy raw-bound statement produced. + */ + async insertReservationCopy(input: { + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + accommodation_id: string | null; + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + status: string | null; + type: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + ingest_state: string; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + endDay: input.end_day_id, + place: input.place_id, + assignment: input.assignment_id, + accommodation_id: input.accommodation_id, + title: input.title, + reservation_time: input.reservation_time, + reservation_end_time: input.reservation_end_time, + location: input.location, + confirmation_number: input.confirmation_number, + notes: input.notes, + url: input.url, + status: input.status, + type: input.type, + metadata: input.metadata, + day_plan_position: input.day_plan_position, + needs_review: input.needs_review, + ingest_state: input.ingest_state, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 4 — additive (see the file-level comment above the interfaces + // this section's methods return). + // --------------------------------------------------------------------------- + + /** CL2 — see {@link CalendarReservationRow}'s docstring. */ + async listForCalendar(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservations as r') + .leftJoin('places as pl', 'pl.id', 'r.place_id') + .leftJoin('day_accommodations as a', 'a.id', 'r.accommodation_id') + .leftJoin('days as sd', 'sd.id', 'a.start_day_id') + .leftJoin('days as ed', 'ed.id', 'a.end_day_id') + .leftJoin('days as rd', 'rd.id', 'r.day_id') + .leftJoin('days as red', 'red.id', 'r.end_day_id') + .selectAll('r') + .select((eb) => [ + 'pl.lat as place_lat', + 'pl.lng as place_lng', + 'sd.date as stay_start_date', + 'ed.date as stay_end_date', + 'a.check_in as stay_check_in', + 'a.check_out as stay_check_out', + eb + .selectFrom('reservations as r2') + .select((eb2) => eb2.fn.min('r2.id').as('c')) + .whereRef('r2.accommodation_id', '=', 'a.id') + .as('stay_first_reservation_id'), + 'rd.date as day_date', + 'red.date as end_day_date', + ]) + .where('r.trip_id', '=', trip_id) + .where((eb) => publicReservationExpr(eb, 'r.ingest_state')) + .execute(); + return rows as CalendarReservationRow[]; + } + + /** CL4 — see {@link CalendarStopRow}'s docstring. */ + async listCalendarStops(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .selectAll('da') + .select((eb) => [ + 'p.name as place_name', + 'p.address as place_address', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb.fn.coalesce('da.assignment_time', 'p.place_time').as('effective_time'), + eb.fn.coalesce('da.assignment_end_time', 'p.end_time').as('effective_end_time'), + ]) + .where('da.day_id', '=', day_id) + .where('da.accommodation_id', 'is', null) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .execute(); + return rows as CalendarStopRow[]; + } + + /** CL7 — see {@link CalendarStayRow}'s docstring. */ + async listPublicStaysForCalendar(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .leftJoin('days as sd', 'sd.id', 'a.start_day_id') + .leftJoin('days as ed', 'ed.id', 'a.end_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id', + 'a.check_in', + 'a.check_in_end', + 'a.check_out', + 'sd.date as start_date', + 'ed.date as end_date', + 'p.name as place_name', + 'p.address as place_address', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb + .selectFrom('reservations as r') + .select('r.title') + .whereRef('r.accommodation_id', '=', 'a.id') + .where((eb2) => publicReservationExpr(eb2, 'r.ingest_state')) + .orderBy('r.id', 'asc') + .limit(1) + .as('reservation_title'), + ]) + .orderBy('a.id', 'asc') + .execute(); + return rows as CalendarStayRow[]; + } + + /** RS20 — see {@link UpcomingReservationRow}'s docstring. */ + async listUpcomingForUser(user_id: number, today: string, now_hhmm: string, limit: number): Promise { + const platform = this.getEntityManager().getPlatform(); + + // The `COALESCE(place name, nameless-stay's-linked-booking title, trip + // title)` title is identical across the check-in and check-out arms + // (both name the STAY, not the moment); it is spelled out in each rather + // than factored into a shared helper, matching `Trips.repository.ts`'s + // `activeTrip` precedent for its own repeated `CASE WHEN` — a helper + // spanning two differently-joined query builders would need the same + // kind of hand-written cross-callback type `_shared/reservation-visibility + // .ts`'s own module docstring explains doesn't hold up. + const rows = await this.kysely() + .with('visible_trips', (qb) => + qb + .selectFrom('trips as t') + .leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', user_id)) + .select(['t.id', 't.title', 't.cover_image']) + .where((eb) => + eb.and([eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', 'is not', null)]), eb('t.is_archived', '=', 0)]), + ), + ) + .with('entries', (qb) => { + const bookings = qb + .selectFrom('reservations as r') + .innerJoin('visible_trips as tr', 'tr.id', 'r.trip_id') + .leftJoin('days as d', 'd.id', 'r.day_id') + .leftJoin('places as p', 'p.id', 'r.place_id') + .select((eb) => [ + 'r.id as id', + 'r.trip_id as trip_id', + 'r.title as title', + 'r.type as type', + 'r.status as status', + 'r.location as location', + 'r.reservation_time as reservation_time', + 'r.confirmation_number as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + eb + .case() + .when(startsWithIsoDateKysely(platform, eb, 'r.reservation_time')) + .then(substringKysely(platform, eb, 'r.reservation_time', 1, 10)) + .else(eb.ref('d.date')) + .end() + .as('at_date'), + eb + .case() + .when(startsWithIsoDateKysely(platform, eb, 'r.reservation_time')) + .then(substringKysely(platform, eb, 'r.reservation_time', 12)) + .else(eb.ref('r.reservation_time')) + .end() + .as('at_time'), + ]) + .where('r.status', '!=', 'cancelled') + .where((eb) => eb(eb.fn.coalesce(eb.ref('r.type'), eb.val('')), '!=', 'hotel')) + .$castTo(); + + const checkins = qb + .selectFrom('day_accommodations as a') + .innerJoin('visible_trips as tr', 'tr.id', 'a.trip_id') + .innerJoin('days as d', 'd.id', 'a.start_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.trip_id as trip_id', + eb + .fn.coalesce( + eb.ref('p.name'), + eb + .selectFrom('reservations as res') + .select('res.title') + .where((eb2) => eb2(castIntegerKysely(platform, eb2, 'res.accommodation_id'), '=', eb2.ref('a.id'))) + .where('res.status', '!=', 'cancelled') + .orderBy('res.id', 'asc') + .limit(1), + eb.ref('tr.title'), + ) + .as('title'), + eb.val('checkin').as('type'), + eb.val('confirmed').as('status'), + eb.val(null).as('location'), + eb + .case() + .when('a.check_in', 'is not', null) + .then(concatKysely(platform, eb, { column: 'd.date' }, { value: 'T' }, { column: 'a.check_in' })) + .end() + .as('reservation_time'), + 'a.confirmation as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + 'd.date as at_date', + 'a.check_in as at_time', + ]) + .$castTo(); + + const checkouts = qb + .selectFrom('day_accommodations as a') + .innerJoin('visible_trips as tr', 'tr.id', 'a.trip_id') + .innerJoin('days as d', 'd.id', 'a.end_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.trip_id as trip_id', + eb + .fn.coalesce( + eb.ref('p.name'), + eb + .selectFrom('reservations as res') + .select('res.title') + .where((eb2) => eb2(castIntegerKysely(platform, eb2, 'res.accommodation_id'), '=', eb2.ref('a.id'))) + .where('res.status', '!=', 'cancelled') + .orderBy('res.id', 'asc') + .limit(1), + eb.ref('tr.title'), + ) + .as('title'), + eb.val('checkout').as('type'), + eb.val('confirmed').as('status'), + eb.val(null).as('location'), + eb + .case() + .when('a.check_out', 'is not', null) + .then(concatKysely(platform, eb, { column: 'd.date' }, { value: 'T' }, { column: 'a.check_out' })) + .end() + .as('reservation_time'), + 'a.confirmation as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + 'd.date as at_date', + 'a.check_out as at_time', + ]) + .$castTo(); + + return bookings.unionAll(checkins).unionAll(checkouts); + }) + .selectFrom('entries') + .select(['id', 'trip_id', 'title', 'type', 'status', 'location', 'reservation_time', 'confirmation_number', 'trip_title', 'trip_cover', 'day_date', 'place_name', 'place_image']) + .where('at_date', 'is not', null) + .where((eb) => + eb.or([ + eb('at_date', '>', today), + eb.and([eb('at_date', '=', today), eb(eb.fn.coalesce(eb.ref('at_time'), eb.val('23:59')), '>=', now_hhmm)]), + ]), + ) + .orderBy('at_date', 'asc') + .orderBy((eb) => eb.fn.coalesce(eb.ref('at_time'), eb.val('00:00')), 'asc') + .orderBy('id', 'asc') + .limit(limit) + .execute(); + return rows as UpcomingReservationRow[]; + } + + /** `share.service.ts:251` — see {@link ShareEndpointRow}'s docstring. */ + async listEndpointsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .select(['e.reservation_id', 'e.role', 'e.sequence', 'e.name', 'e.code', 'e.lat', 'e.lng', 'e.timezone', 'e.local_date', 'e.local_time']) + .where('r.trip_id', '=', trip_id) + .orderBy('e.reservation_id', 'asc') + .orderBy('e.sequence', 'asc') + .execute(); + return rows as ShareEndpointRow[]; + } + + /** `share.service.ts:368` — see {@link ShareDayPositionRow}'s docstring. */ + async listDayPositionsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_day_positions as rdp') + .innerJoin('reservations as r', 'r.id', 'rdp.reservation_id') + .select(['rdp.reservation_id', 'rdp.day_id', 'rdp.position']) + .where('r.trip_id', '=', trip_id) + .execute(); + return rows as ShareDayPositionRow[]; + } + + /** `share.service.ts:387` — see {@link SharePublicReservationRow}'s docstring. */ + async listPublicForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservations as r') + .select([ + 'r.id', 'r.trip_id', 'r.day_id', 'r.end_day_id', 'r.place_id', 'r.accommodation_id', + 'r.title', 'r.type', 'r.status', 'r.location', 'r.reservation_time', 'r.reservation_end_time', + 'r.notes', 'r.url', 'r.metadata', 'r.created_at', + ]) + .where('r.trip_id', '=', trip_id) + .where((eb) => publicReservationExpr(eb, 'r.ingest_state')) + .orderBy('r.reservation_time', 'asc') + .execute(); + return rows as SharePublicReservationRow[]; + } + + /** `share.service.ts:401` — see {@link SharePublicAccommodationRow}'s docstring. */ + async listPublicAccommodationsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .innerJoin('places as p', 'p.id', 'a.place_id') + .select([ + 'a.id', 'a.trip_id', 'a.place_id', 'a.start_day_id', 'a.end_day_id', + 'a.check_in', 'a.check_in_end', 'a.check_out', 'a.notes', + 'p.name as place_name', 'p.address as place_address', 'p.lat as place_lat', 'p.lng as place_lng', + ]) + .execute(); + return rows as SharePublicAccommodationRow[]; + } + + /** + * `share.service.ts`'s travel-only read (SH19, #1712) — `SELECT DISTINCT + * a.place_id FROM day_accommodations a WHERE a.trip_id = ? AND `: + * the places the trip sleeps at, through the same public-stay predicate as + * {@link listPublicAccommodationsForShare}. + */ + async listPublicStayPlaceIdsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .select('a.place_id') + .distinct() + .execute(); + return rows.map((r) => r.place_id as number); + } + + /** `public-api.service.ts::reservationsByDay` (Task 5's `// Task 2` pickup) — see {@link PublicApiScheduledReservationRow}'s docstring. */ + async listScheduledForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['day_id', 'type', 'title', 'location', 'reservation_time', 'reservation_end_time', 'status', 'notes']) + .where('trip_id', '=', trip_id) + .where('day_id', 'is not', null) + .orderBy('day_id', 'asc') + .orderBy('reservation_time', 'asc') + .execute(); + return rows as PublicApiScheduledReservationRow[]; + } + + /** `public-api.service.ts::buildUnscheduledReservations` (Task 5's `// Task 2` pickup) — see {@link PublicApiUnscheduledReservationRow}'s docstring. */ + async listUnscheduledForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['type', 'title', 'location', 'reservation_time', 'reservation_end_time', 'status', 'notes']) + .where('trip_id', '=', trip_id) + .where('day_id', 'is', null) + .orderBy('reservation_time', 'asc') + .orderBy('id', 'asc') + .execute(); + return rows as PublicApiUnscheduledReservationRow[]; + } + + /** `public-api.service.ts::buildAccommodations` (Task 5's `// Task 3` pickup) — see {@link PublicApiAccommodationRow}'s docstring. */ + async listAccommodationsForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('days as ds', 'ds.id', 'a.start_day_id') + .leftJoin('days as de', 'de.id', 'a.end_day_id') + .select(['p.name', 'p.address', 'p.lat', 'p.lng', 'ds.date as start_date', 'de.date as end_date', 'a.check_in', 'a.check_out', 'a.notes']) + .where('a.trip_id', '=', trip_id) + .orderBy('ds.date', 'asc') + .execute(); + return rows as PublicApiAccommodationRow[]; + } + + /** `public-api.service.ts::buildUnplannedPlaces` (Task 5's `// Task 3` pickup) — see {@link PublicApiUnplannedPlaceRow}'s docstring. */ + async listUnplannedPlacesForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('places as p') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select((eb) => [ + 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time', + 'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category', + ]) + .where('p.trip_id', '=', trip_id) + .where((eb) => eb.not(eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')))) + .where((eb) => eb.not(eb.exists(eb.selectFrom('day_accommodations as a').select('a.id').whereRef('a.place_id', '=', 'p.id')))) + .orderBy('p.created_at', 'asc') + .orderBy('p.id', 'asc') + .execute(); + return rows as PublicApiUnplannedPlaceRow[]; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL1 (`FilesService.findForeignLinkTarget`'s reservation branch) — + * `SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?`, re-expressed + * as "what trip does this row belong to" (R12: one method per target + * table, no dynamic identifier dispatch — the RS21/RS23/RS25 D4 precedent) + * so the caller compares the returned `trip_id` against its own, already + * `toRowId`-parsed value instead of binding two ids into the query itself. + * `trip_id` is a `persist(false)` mirror of the `trip` relation — + * `columnRef`, not a bare select (the program-wide trap). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('r') + .select([columnRef(platform, 'r.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule: BG54/85, BG55/86, BG56/87 — the mirrored-price + // read/write/re-select `BudgetService.clearReservationPrice`/ + // `syncReservationPrice` run against a reservation. Kysely, matching + // `findTripId` above's reasoning: `trip_id` is a `persist(false)` mirror. + // --------------------------------------------------------------------------- + + /** BG54/BG85 — `SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?`. */ + async getIdAndMetadata(id: number | string, trip_id: number | string): Promise<{ id: number; metadata: string | null } | undefined> { + return await this.kysely<{ reservations: { id: number; trip_id: number; metadata: string | null } }>() + .selectFrom('reservations') + .select(['id', 'metadata']) + .where('id', '=', id as number) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + } + + /** BG55/BG86 — `UPDATE reservations SET metadata = ? WHERE id = ?`. */ + async setMetadata(id: number | string, metadata: string): Promise { + await this.kysely<{ reservations: { id: number; metadata: string } }>() + .updateTable('reservations') + .set({ metadata }) + .where('id', '=', id as number) + .execute(); + } + + /** BG56/BG87 — `SELECT * FROM reservations WHERE id = ?`, the mirrored-price re-select for the `reservation:updated` broadcast. */ + async getFull(id: number | string): Promise { + return await this.kysely<{ reservations: ReservationAllColumnsRow }>() + .selectFrom('reservations') + .selectAll() + .where('id', '=', id as number) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (AirTrail's bookkeeping, `nest/integrations/airtrail*.ts` — + // owns zero tables of its own, R7) + airports' flight-endpoint backfill + // (`nest/airports/airports.service.ts`) — additive, per this task's own + // file-ownership rule ("additive methods on Reservations/ReservationEndpoints/ + // Days/Places/Users repositories"). `trip_id` is a `persist(false)` FK + // mirror — every trip-scoped read below filters by the `trip` ref property + // (the `findInTrip`/`listResyncCandidates` precedent above), never a raw + // column name. + // --------------------------------------------------------------------------- + + /** ATI1 (`airtrail-import.service.ts#importAirtrailFlights`, dedup: already-linked ids) — `SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'`. */ + async listAirtrailLinkedForTrip(trip_id: number): Promise<{ external_id: string | null; metadata: string | null }[]> { + return this.qb('r') + .select(['r.external_id', 'r.metadata']) + .where({ trip: trip_id, external_source: 'airtrail' }) + .execute<{ external_id: string | null; metadata: string | null }[]>('all', false); + } + + /** ATI2 (`airtrail-import.service.ts#importAirtrailFlights`, existing-flight signature dedup) — `SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`. */ + async listFlightReservationsForTrip(trip_id: number): Promise<{ id: number; reservation_time: string | null; metadata: string | null }[]> { + return this.qb('r') + .select(['r.id', 'r.reservation_time', 'r.metadata']) + .where({ trip: trip_id, type: 'flight' }) + .execute<{ id: number; reservation_time: string | null; metadata: string | null }[]>('all', false); + } + + /** + * ATI5 (`airtrail-import.service.ts#importAirtrailFlights`, multi-leg + * branch) — `UPDATE reservations SET external_source = 'airtrail', + * external_id = ?, external_owner_user_id = ?, sync_enabled = 0, + * external_synced_at = ? WHERE id = ?`. **CORRECTNESS GAP, pre-existing, + * report don't fix** (inventory §3b): neither this nor {@link + * linkAirtrailSingleFlight} is transactional with the preceding + * `ReservationsService.create` call — a failure between the two leaves an + * untracked reservation. Preserved exactly, not "fixed" with an added + * `uow.transactional` wrap. + */ + async linkAirtrailMultiLeg(id: number, external_id: string, external_owner_user_id: number, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 0, external_synced_at }); + } + + /** + * ATI6 (`airtrail-import.service.ts#importAirtrailFlights`, single-flight + * branch) — `UPDATE reservations SET external_source = 'airtrail', + * external_id = ?, external_owner_user_id = ?, sync_enabled = 1, + * external_hash = ?, external_synced_at = ? WHERE id = ?`. Same + * pre-existing tx gap as {@link linkAirtrailMultiLeg} — report don't fix. + */ + async linkAirtrailSingleFlight(id: number, external_id: string, external_owner_user_id: number, external_hash: string, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 1, external_hash, external_synced_at }); + } + + /** ATL2 (`airtrail-link.service.ts#detach`) — `UPDATE reservations SET sync_enabled = 0 WHERE id = ?`. */ + async setAirtrailSyncDisabled(id: number): Promise { + await this.nativeUpdate({ id }, { sync_enabled: 0 }); + } + + /** + * ATL4 (`airtrail-link.service.ts#pushReservationToAirtrail`) — `SELECT + * id, trip_id, external_id, external_owner_user_id, sync_enabled FROM + * reservations WHERE id = ? AND external_source = 'airtrail'`. `trip_id` + * via `columnRef` (the `listResyncCandidates` precedent above) — the + * mirror column, not a bare select. + */ + async findAirtrailLinked(id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_owner_user_id', 'r.sync_enabled']) + .where({ id, external_source: 'airtrail' }) + .execute<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined>('get', false); + } + + /** + * ATL5 (`airtrail-link.service.ts#pushReservationToAirtrail`, self-write + * suppression) / ATS2 (`airtrail-sync.service.ts#syncOwner`, byte-identical + * 2nd call site) — `UPDATE reservations SET external_hash = ?, + * external_synced_at = ? WHERE id = ?`. One method, two call sites (D4). + */ + async setAirtrailSyncStamp(id: number, external_hash: string, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_hash, external_synced_at }); + } + + /** + * ATS1 (`airtrail-sync.service.ts#syncOwner`) — `SELECT id, trip_id, + * external_id, external_hash FROM reservations WHERE external_source = + * 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?`. + */ + async listAirtrailSyncCandidatesForOwner(owner_user_id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_hash']) + .where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: owner_user_id }) + .execute<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]>('all', false); + } + + /** + * ATS3 (`airtrail-sync.service.ts#runAirtrailSync`) — `SELECT DISTINCT + * external_owner_user_id AS uid FROM reservations WHERE external_source = + * 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL`. + * **NON-HTTP ENTRYPOINT SOURCE** — the cron poll's owner list. + */ + async listAirtrailSyncOwners(): Promise { + const rows = await this.qb('r') + .select(['r.external_owner_user_id']) + .distinct() + .where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: { $ne: null } }) + .execute<{ external_owner_user_id: number }[]>('all', false); + return rows.map((row) => row.external_owner_user_id); + } + + /** + * AIR1 (`airports.service.ts#backfillFlightEndpoints`) — `SELECT r.id, + * r.metadata, r.reservation_time, r.reservation_end_time FROM reservations + * r WHERE r.type = 'flight' AND NOT EXISTS (SELECT 1 FROM + * reservation_endpoints e WHERE e.reservation_id = r.id)`. **NON-HTTP + * ENTRYPOINT SOURCE** — the boot-sweep backfill; idempotent via this NOT + * EXISTS guard, same shape as `Reservations.repository.ts`'s own + * `listUnplannedPlacesForPublicApi` NOT EXISTS precedent (`eb.not(eb.exists(...))`). + */ + async listFlightsMissingEndpoints(): Promise<{ id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]> { + const rows = await this.kysely() + .selectFrom('reservations as r') + .select(['r.id', 'r.metadata', 'r.reservation_time', 'r.reservation_end_time']) + .where('r.type', '=', 'flight') + .where((eb) => eb.not(eb.exists(eb.selectFrom('reservation_endpoints as e').select('e.id').whereRef('e.reservation_id', '=', 'r.id')))) + .execute(); + return rows; + } + + /** AIR3 (`airports.service.ts#backfillFlightEndpoints`, looped) — `UPDATE reservations SET needs_review = 1 WHERE id = ?`. */ + async markNeedsReview(id: number): Promise { + await this.nativeUpdate({ id }, { needs_review: 1 }); + } + + /** RPL4/RPL5's shared select list, the columns of `RoadtripCarrierRow`. */ + private roadtripCarrierQuery() { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select([ + 'r.id', + 'r.type', + 'r.title', + columnRef(platform, 'r.day_id').as('day_id'), + columnRef(platform, 'r.end_day_id').as('end_day_id'), + 'r.reservation_time', + 'r.reservation_end_time', + 'r.metadata', + 'r.day_plan_position', + ]); + } + + /** + * RPL4 (`RoadtripPlanService.carriers`) — `SELECT id, type, title, day_id, + * end_day_id, reservation_time, reservation_end_time, metadata, + * day_plan_position FROM reservations WHERE trip_id = ? AND type IN + * ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT + * NULL`: the bookings that seam the drive, and the hire cars whose desks + * stand on it. + */ + async listRoadtripCarriers(trip_id: number): Promise { + return this.roadtripCarrierQuery() + .where({ trip: trip_id, type: { $in: ['flight', 'train', 'ferry', 'cruise', 'bus', 'car'] }, day: { $ne: null } }) + .execute('all', false); + } + + /** + * RPL5 (`RoadtripPlanService.undatedRides`) — `SELECT id, type, title, + * day_id, end_day_id, reservation_time, reservation_end_time, metadata, + * day_plan_position FROM reservations WHERE trip_id = ? AND day_id IS + * NULL`. Which of them count as rides is `undatedRides` (shared) alone. + */ + async listRoadtripUndated(trip_id: number): Promise { + return this.roadtripCarrierQuery() + .where({ trip: trip_id, day: null }) + .execute('all', false); + } +} + +/** + * RPL4/RPL5's row: a booking the traveller rides, with what the road trip's + * seam needs of it (the days and clocks at both ends, where the day plan + * seats it). The terminals and day positions are joined on in the service. + */ +export interface RoadtripCarrierRow { + id: number; + type: string; + title: string; + day_id: number | null; + end_day_id: number | null; + reservation_time: string | null; + reservation_end_time: string | null; + metadata: string | null; + day_plan_position: number | null; +} + +/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */ +export interface ReservationAllColumnsRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; +} diff --git a/server/src/db/repositories/RoadtripDayBoundaries.repository.ts b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts new file mode 100644 index 000000000..81741644c --- /dev/null +++ b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts @@ -0,0 +1,101 @@ +import type { RoadtripDayBoundary } from '@trek/shared'; +import type { RoadtripDayBoundaries } from '../entities/RoadtripDayBoundaries.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `roadtrip_day_boundaries` — where a manual day ending sits (a stop, or a + * fraction along the driving leg between two consecutive stops), overriding + * the automatic day-ending rule daily travel times uses. + */ +export class RoadtripDayBoundariesRepository extends TrekRepository { + /** + * RB1 — `SELECT day_number, from_assignment_id, to_assignment_id, fraction + * FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`. + * + * `columnRef` for `from_assignment_id`/`to_assignment_id` — both are + * `persist(false)` mirrors of the `fromAssignment`/`toAssignment` + * relations, and selecting either bare (`'b.from_assignment_id'`) drops it + * from the result silently, EVEN with no active join on that relation + * (verified directly here, not assumed from `PlaceRatingsRepository + * .listForPlaces`'s narrower, join-only framing of the same trap). + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('b') + .select(['b.day_number', columnRef(platform, 'b.from_assignment_id'), columnRef(platform, 'b.to_assignment_id'), 'b.fraction']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + /** + * RB3 — `INSERT INTO roadtrip_day_boundaries (trip_id, day_number, + * from_assignment_id, to_assignment_id, fraction) VALUES (?×5) ON CONFLICT + * (trip_id, day_number) DO UPDATE SET from_assignment_id = + * excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, + * fraction = excluded.fraction`. + * + * `onConflictFields: ['trip', 'day_number']` — the composite PK the + * migration and the entity's own `[PrimaryKeyProp]?: ['trip', + * 'day_number']` both declare; `onConflictMergeFields` names the three + * relation/scalar properties the legacy SET list names (`fromAssignment`/ + * `toAssignment`, not their `persist(false)` `_id` mirrors — + * `OauthConsentsRepository.upsertGrant`'s precedent), so a conflict never + * touches `trip`/`day_number` themselves (the key) or any future column + * this entity gains. + */ + async upsertBoundary(trip_id: number, boundary: RoadtripDayBoundary): Promise { + await this.upsert( + { + trip: trip_id, + day_number: boundary.day_number, + fromAssignment: boundary.from_assignment_id, + toAssignment: boundary.to_assignment_id, + fraction: boundary.fraction, + }, + { onConflictFields: ['trip', 'day_number'], onConflictAction: 'merge', onConflictMergeFields: ['fromAssignment', 'toAssignment', 'fraction'] }, + ); + } + + /** RB4 — `DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?`, silent on a miss. */ + async deleteForDay(trip_id: number, day_number: number): Promise { + await this.nativeDelete({ trip: trip_id, day_number }); + } + + /** + * RB5 (`DaysService.shiftBoundariesBack`, the dated append) — `SELECT + * day_number FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number + * >= ? ORDER BY day_number DESC`. + */ + async listDayNumbersFrom(trip_id: number, from_number: number): Promise { + const rows = await this.qb('b') + .select(['b.day_number']) + .where({ trip: trip_id, day_number: { $gte: from_number } }) + .orderBy({ day_number: 'desc' }) + .execute<{ day_number: number }[]>('all', false); + return rows.map((r) => r.day_number); + } + + /** + * RB6 (`DayRemovalService.shiftBoundaries`) — `SELECT day_number FROM + * roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`. + */ + async listDayNumbers(trip_id: number): Promise { + const rows = await this.qb('b') + .select(['b.day_number']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute<{ day_number: number }[]>('all', false); + return rows.map((r) => r.day_number); + } + + /** + * RB7 (`DaysService.shiftBoundariesBack`, `DayRemovalService.shiftBoundaries`) + * — `UPDATE roadtrip_day_boundaries SET day_number = ? WHERE trip_id = ? AND + * day_number = ?`. + */ + async moveDayNumber(trip_id: number, from_number: number, to_number: number): Promise { + await this.nativeUpdate({ trip: trip_id, day_number: from_number }, { day_number: to_number }); + } +} diff --git a/server/src/db/repositories/RoadtripDayTracks.repository.ts b/server/src/db/repositories/RoadtripDayTracks.repository.ts new file mode 100644 index 000000000..cf62573a2 --- /dev/null +++ b/server/src/db/repositories/RoadtripDayTracks.repository.ts @@ -0,0 +1,71 @@ +import type { RoadtripDayTrack } from '@trek/shared'; +import type { RoadtripDayTracks } from '../entities/RoadtripDayTracks.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `roadtrip_day_tracks` — which recorded track (a `places` row with route + * geometry) a day's drive was fitted to, and how far the fitted route still + * strayed from it. + */ +export class RoadtripDayTracksRepository extends TrekRepository { + /** RT7 — `DELETE FROM roadtrip_day_tracks WHERE day_id = ?` (`createMany`'s explicit `track: null` clear). */ + async deleteForDay(day_id: number): Promise { + await this.nativeDelete({ day: day_id }); + } + + /** + * RT8 — `INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) + * VALUES (?, ?, ?) ON CONFLICT(day_id) DO UPDATE SET place_id = + * excluded.place_id, stray_km = excluded.stray_km`. + * + * `onConflictFields: ['day']` — the RELATION property, not the `day_id` + * scalar mirror (`persist(false)`, not a real column MikroORM's upsert + * builder can target) — the same naming `PlaceRatingsRepository + * .upsertRating`/`TripMembersRepository`'s own composite-key upserts use + * for a relation-backed conflict target. `RoadtripDayTracks.day` is this + * table's PRIMARY KEY (a nullable one-to-one, per Task 0's `RULE12` + * finding — ruled NOT a generator defect, matching the `VacayUserSettings` + * precedent), so the single-element list targets the table's actual PK, + * `roadtrip_day_tracks(day_id)`. Proven, not assumed: `RoadtripDayTracks + * .repository.test.ts` (`UPSERTTRACKREPO-*`) upserts the SAME `day_id` + * twice with different `place_id`/`stray_km` and asserts exactly one row + * survives with the SECOND call's values — if `onConflictFields: ['day']` + * resolved to anything other than the physical `day_id` column, SQLite's + * own `ON CONFLICT` target-matching would either throw (no matching + * unique index) or let the second call's row collide on the real PK and + * raise `UNIQUE constraint failed`, so the test is a genuine proof, not a + * behavioural coincidence. `created_at` is named in neither + * `onConflictMergeFields` NOR the insert payload beyond its own default, + * so a conflict never refreshes it (pinned — the legacy statement's SET + * list never named it either). + */ + async upsertTrack(day_id: number, place_id: number, stray_km: number | null): Promise { + await this.upsert( + { day: day_id, place: place_id, stray_km }, + { onConflictFields: ['day'], onConflictAction: 'merge', onConflictMergeFields: ['place', 'stray_km'] }, + ); + } + + /** + * RT12 — `SELECT t.day_id, t.place_id, t.stray_km FROM roadtrip_day_tracks + * t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ? ORDER BY t.day_id`. + * + * `columnRef` for both `day_id` (actively joined via `t.day`) and + * `place_id` (not joined here, but selected the same defensive way for + * consistency with every other persist(false) mirror in this cluster — + * `PlaceRatingsRepository.listForPlaces`'s docstring is the verified trap + * this sidesteps). Ordered by `d.id` (`= t.day_id` by the join condition) + * rather than the mirror column itself, the same reason + * `RoadtripViasRepository.listForTrip` orders by `d.id`. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('t') + .join('t.day', 'd') + .select([columnRef(platform, 't.day_id'), columnRef(platform, 't.place_id'), 't.stray_km']) + .where({ 'd.trip': trip_id }) + .orderBy({ 'd.id': 'asc' }) + .execute('all', false); + } +} diff --git a/server/src/db/repositories/RoadtripPreferences.repository.ts b/server/src/db/repositories/RoadtripPreferences.repository.ts new file mode 100644 index 000000000..66224967a --- /dev/null +++ b/server/src/db/repositories/RoadtripPreferences.repository.ts @@ -0,0 +1,35 @@ +import type { RoadtripPreferences } from '../entities/RoadtripPreferences.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** RPF1's raw row — the JSON parse (with its raw-string fallback) stays in the service. */ +export interface RoadtripPreferenceRow { + key: string; + value: string; +} + +/** + * `roadtrip_preferences` — a trip-scoped key/value store, one row per + * preference key, values JSON-encoded in the service. + */ +export class RoadtripPreferencesRepository extends TrekRepository { + /** RPF1 — `SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?` (no ORDER BY, matching the legacy statement). */ + async listForTrip(trip_id: number): Promise { + return await this.qb('r') + .select(['r.key', 'r.value']) + .where({ trip: trip_id }) + .execute('all', false); + } + + /** + * RPF3 — `INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES + * (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value`. + * + * `onConflictFields: ['trip', 'key']` — the composite PK + * (`[PrimaryKeyProp]?: ['trip', 'key']`); `onConflictMergeFields: + * ['value']` matches the legacy SET list exactly (never touches `trip`/ + * `key`, the key itself). + */ + async upsertValue(trip_id: number, key: string, value: string): Promise { + await this.upsert({ trip: trip_id, key, value }, { onConflictFields: ['trip', 'key'], onConflictAction: 'merge', onConflictMergeFields: ['value'] }); + } +} diff --git a/server/src/db/repositories/RoadtripVias.repository.ts b/server/src/db/repositories/RoadtripVias.repository.ts new file mode 100644 index 000000000..0049ffb13 --- /dev/null +++ b/server/src/db/repositories/RoadtripVias.repository.ts @@ -0,0 +1,175 @@ +import type { RoadtripVia } from '@trek/shared'; +import type { AnchoredVia } from '@trek/shared/roadtrip'; +import type { RoadtripVias } from '../entities/RoadtripVias.entity'; +import { columnRef, maxOf } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +const _roadtripViaRowKeys: AssertRowKeys = true; + +/** RT18/AC13 — the OLD anchor a re-anchor reads before any write. */ +export interface RoadtripViaAnchorRow { + id: number; + after_order_index: number; + sequence: number; +} + +/** RT21 — the same day's vias without `sequence` (the renumber pass reads it separately). */ +export interface RoadtripViaLegRow { + id: number; + after_order_index: number; +} + +/** + * `roadtrip_vias` — the points a day's drive is routed through without stopping. + * + * `RT2` (`listForDay`) is the ONE copy of that projection: `AccommodationsService` + * (AC16, Task 3) and `AssignmentsService` (AS23, `listDayVias`) both call it now, + * retiring the "deliberate duplicate" the legacy `AssignmentsService` docstring + * used to carry. + */ +export class RoadtripViasRepository extends TrekRepository { + /** RT2/AC16/AS23 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id`. */ + async listForDay(day_id: number): Promise { + const vias = await this.find({ day: day_id }, { orderBy: { after_order_index: 'asc', sequence: 'asc', id: 'asc' } }); + return vias.map((v) => toRow(v) as RoadtripVia); + } + + /** + * RT3 — `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, + * v.created_at FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE + * d.trip_id = ? ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`. + * + * Ordered by `d.id` rather than the joined table's own `v.day_id` (always the + * same value, by the join condition): `v.day_id` is a `persist(false)` mirror + * of the `day` relation and selecting or ordering by it directly, alongside an + * active join on that same relation, is the exact trap `PlaceRatingsRepository + * .listForPlaces`'s docstring documents (the column silently aliases off the + * JOINED entity instead) — `columnRef` below sidesteps it for the SELECT list + * the same way `AssignmentWithPlaceRow`'s `category_id` does; ordering by + * `d.id` sidesteps it for the ORDER BY without needing a third mechanism. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('v') + .join('v.day', 'd') + .select(['v.id', columnRef(platform, 'v.day_id'), 'v.after_order_index', 'v.sequence', 'v.lat', 'v.lng', 'v.created_at']) + .where({ 'd.trip': trip_id }) + .orderBy({ 'd.id': 'asc', 'v.after_order_index': 'asc', 'v.sequence': 'asc', 'v.id': 'asc' }) + .execute('all', false); + } + + /** + * RT4/RT10 — `SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM + * roadtrip_vias WHERE day_id = ? AND after_order_index = ?`, read as a plain + * `MAX()` (`maxOf`) folded to the same result in JS: no rows (or an + * all-`NULL` `sequence`, impossible here since the column is `NOT NULL + * DEFAULT 0`) reads back `null`, and `(null ?? -1) + 1 === 0`, matching + * `COALESCE(MAX(sequence) + 1, 0)`'s own null branch; a real max `m` reads + * back `m`, and `(m ?? -1) + 1 === m + 1`, matching `MAX(sequence) + 1`. + */ + async nextSequence(day_id: number, after_order_index: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('v') + .select([maxOf(platform, 'v.sequence', 'max_seq')]) + .where({ day: day_id, after_order_index }) + .execute<{ max_seq: number | null } | undefined>('get', false); + return (row?.max_seq ?? -1) + 1; + } + + /** RT5/RT11/TP43 — `INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?×5)`, returning the new id. */ + async insertVia(input: { day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }): Promise { + return await this.insert({ + day: input.day_id, + after_order_index: input.after_order_index, + sequence: input.sequence, + lat: input.lat, + lng: input.lng, + }); + } + + /** RT9 — `DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?` (one leg's own vias, `replace_legs`). */ + async deleteLeg(day_id: number, after_order_index: number): Promise { + await this.nativeDelete({ day: day_id, after_order_index }); + } + + /** RT14 — `SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?`, the scoping check `move`'s two un-transacted UPDATEs (R7) rely on. */ + async existsInDay(id: number, day_id: number): Promise { + const row = await this.qb('v') + .select(['v.id']) + .where({ id, day: day_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * RT15 — `UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?`. No + * `day_id` scoping, matching the legacy statement exactly (R7 — an + * un-transacted check-then-act behind {@link existsInDay}; flagged, not + * fixed). + */ + async moveCoordinates(id: number, lat: number, lng: number): Promise { + await this.nativeUpdate({ id }, { lat, lng }); + } + + /** RT16 — `UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?`. Same R7 scoping note as {@link moveCoordinates}. */ + async moveCoordinatesAndAnchor(id: number, lat: number, lng: number, after_order_index: number): Promise { + await this.nativeUpdate({ id }, { lat, lng, after_order_index }); + } + + /** RT18/AC13 — `SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — matches the legacy statement). */ + async listAnchors(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index', 'v.sequence']) + .where({ day: day_id }) + .execute('all', false); + } + + /** RT21 — `SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — the caller's own JS sort is total, §18's RT21 note). */ + async listLegs(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index']) + .where({ day: day_id }) + .execute('all', false); + } + + /** RT19/AS21 — `DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?`, silent on a miss (`reanchor`'s per-id remove). */ + async deleteInDay(id: number, day_id: number): Promise { + await this.nativeDelete({ id, day: day_id }); + } + + /** RT23 — the same statement as {@link deleteInDay}, but the caller (`remove`) needs the row count to answer 404 on a miss. */ + async deleteInDayCounted(id: number, day_id: number): Promise { + return await this.nativeDelete({ id, day: day_id }); + } + + /** RT20/AC15/AS22 — `UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?`, silent on a miss. */ + async setAnchor(id: number, day_id: number, after_order_index: number): Promise { + await this.nativeUpdate({ id, day: day_id }, { after_order_index }); + } + + /** RT22 — `UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?`, every row unconditionally (unlike AC17). */ + async setSequence(id: number, day_id: number, sequence: number): Promise { + await this.nativeUpdate({ id, day: day_id }, { sequence }); + } + + /** RT24 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?`. */ + async findById(id: number): Promise { + const via = await this.findOne({ id }); + return via ? (toRow(via) as RoadtripVia) : null; + } + + /** + * AS20 (`AssignmentsService.reanchorVias`) — `SELECT id, after_order_index, + * lat, lng FROM roadtrip_vias WHERE day_id = ?`, the `AnchoredVia` shape + * `reanchorByStopOrder` (`@trek/shared/roadtrip`) reads — a different + * projection from {@link listAnchors} (which carries `sequence`, not + * `lat`/`lng`), so it stays its own method rather than reusing one. + */ + async listForReanchor(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index', 'v.lat', 'v.lng']) + .where({ day: day_id }) + .execute('all', false); + } +} diff --git a/server/src/db/repositories/RouteUsageDaily.repository.ts b/server/src/db/repositories/RouteUsageDaily.repository.ts new file mode 100644 index 000000000..bd9c92537 --- /dev/null +++ b/server/src/db/repositories/RouteUsageDaily.repository.ts @@ -0,0 +1,135 @@ +import type { RouteUsageDaily } from '../entities/RouteUsageDaily.entity'; +import { nowDateOffset } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `route_usage_daily` row exactly as `SELECT *` reads it (RU3's shape, + * snake_case). Named `RouteUsageDailyRow`, not `RouteUsageDayRow` — + * `@trek/shared` already exports a `RouteUsageDayRow` (the camelCase API + * shape `RouteUsageService.rows()` maps this into), and `route-usage.service.ts` + * imports both; a same-named repository export would collide. + */ +export interface RouteUsageDailyRow { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; +} + +interface RouteUsageDailyKyselyDB { + route_usage_daily: { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; + }; +} + +/** + * `route_usage_daily` — a composite-PK `(day, profile, surface, self_hosted)` + * table (`RouteUsageDaily.entity.ts`'s `[PrimaryKeyProp]`, matching R4). + * Kysely throughout: RU2's `ON CONFLICT ... DO UPDATE SET count = count + + * excluded.count` shape (an ADDITIVE upsert) is genuinely beyond `em.upsert` + * (`onConflictAction: 'merge'` always OVERWRITES — see {@link record}'s own + * docstring), and once RU2 needs the escape hatch, the rest of the class + * stays there too rather than mixing QB and Kysely for the same table. + */ +export class RouteUsageDailyRepository extends TrekRepository { + /** + * RU2 (`RouteUsageService.record`, inside the caller's own `uow.transactional`, + * once per batch entry) — `INSERT INTO route_usage_daily (day, profile, + * surface, self_hosted, requests, waypoints, km, failed) VALUES + * (date('now'), ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(day, profile, surface, + * self_hosted) DO UPDATE SET requests = requests + excluded.requests, + * waypoints = waypoints + excluded.waypoints, km = km + excluded.km, + * failed = failed + excluded.failed`. + * + * **R4**: an ADDITIVE upsert, not a blind overwrite — `em.upsert`'s + * `onConflictAction: 'merge'` (the shape `PlaceDetailsCacheRepository + * .upsertEntry` already uses successfully for a composite-key upsert, R8) + * has no way to express `col = col + excluded.col`: merge always REPLACES + * the value with + * the new one, it cannot reference the pre-existing row's own value in the + * SET expression. Kysely's `onConflict(...).doUpdateSet(...)`, each column + * an `eb(col, '+', eb.ref('excluded.col'))` binary-operator expression + * (the `DocumentSyncItemsRepository`-precedent `eb.ref('excluded.x')` shape, + * widened here from a blind `eb.ref(...)` re-assignment to a real `+`), + * is the only shape that renders `col = col + excluded.col`. + * + * `day` is a plain bound parameter, not `date('now')` spelled into the + * statement: the caller resolves "today" ONCE per batch (`todayUtc()`, + * the 3f/`TripsRepository.lastStartedTrip` precedent for a BARE + * `date('now')` with no offset — Task 0's report) rather than this + * repository re-deriving a SQLite date function inline for every entry. + * The legacy statement re-evaluated `date('now')` on every loop + * iteration; a batch's entries import in the same tick, so this cannot + * observably diverge except across a real UTC-midnight boundary mid-loop, + * which the legacy code was equally exposed to (both resolve "today" once + * per entry's own execution instant — the JS resolution just does it a + * few microseconds earlier, at the loop's start rather than inside each + * statement). + */ + async record(entry: { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; + }): Promise { + await this.kysely() + .insertInto('route_usage_daily') + .values(entry) + .onConflict((oc) => + oc.columns(['day', 'profile', 'surface', 'self_hosted']).doUpdateSet({ + requests: (eb) => eb('requests', '+', eb.ref('excluded.requests')), + waypoints: (eb) => eb('waypoints', '+', eb.ref('excluded.waypoints')), + km: (eb) => eb('km', '+', eb.ref('excluded.km')), + failed: (eb) => eb('failed', '+', eb.ref('excluded.failed')), + }), + ) + .execute(); + } + + /** RU3 (`RouteUsageService.rows`) — `SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface`. */ + async rows(): Promise { + const result = await this.kysely() + .selectFrom('route_usage_daily') + .selectAll() + .orderBy('day', 'desc') + .orderBy('profile', 'asc') + .orderBy('surface', 'asc') + .execute(); + return result; + } + + /** + * RU4 (`RouteUsageService.purgeExpired`) — `DELETE FROM route_usage_daily + * WHERE day < date('now', ?)`, `?` bound to `` `-${RETENTION_DAYS} days` ``. + * `nowDateOffset` (Task 0's groundwork helper): `RETENTION_DAYS` + * (`route-usage.service.ts`) is a compile-time module constant, so the + * legacy statement's bound-modifier form and a literal day-count offset + * are the same shape at this call site. `PlaceShadowPicksRepository + * .expireStale`'s `nativeDelete({ created_at: { $lt: nowMinusDays(...) } + * })` is the closest working precedent for the filter shape. + */ + async purgeExpired(retentionDays: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeDelete({ day: { $lt: nowDateOffset(platform, -retentionDays) } }); + } + + /** RU5 (`RouteUsageService.clear`) — `DELETE FROM route_usage_daily`, unscoped (the admin's "start over"). */ + async clear(): Promise { + return this.nativeDelete({}); + } +} diff --git a/server/src/db/repositories/SchemaVersion.repository.ts b/server/src/db/repositories/SchemaVersion.repository.ts new file mode 100644 index 000000000..7523911d3 --- /dev/null +++ b/server/src/db/repositories/SchemaVersion.repository.ts @@ -0,0 +1,4 @@ +import type { SchemaVersion } from '../entities/SchemaVersion.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class SchemaVersionRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchoolHolidayCountries.repository.ts b/server/src/db/repositories/SchoolHolidayCountries.repository.ts new file mode 100644 index 000000000..3aec11875 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayCountries.repository.ts @@ -0,0 +1,69 @@ +import type { SchoolHolidayCountries } from '../entities/SchoolHolidayCountries.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `school_holiday_countries` row as the API emits it (SH1/SH3 — `SchoolHolidayCountryRequest`'s exact shape, `@trek/shared`). */ +export interface SchoolHolidayCountryRow { + code: string; + name: string; +} + +const _schoolHolidayCountryRowKeys: AssertRowKeys = true; + +/** `school_holiday_countries`'s single-table read/write shape (Plan 3f Task 2, SH1/SH3/SH4/SH6). */ +export class SchoolHolidayCountriesRepository extends TrekRepository { + /** SH1 — `SELECT code, name FROM school_holiday_countries ORDER BY name, code`. */ + async list(): Promise { + const countries = await this.find({}, { orderBy: { name: 'asc', code: 'asc' } }); + return countries.map((country) => toRow(country) as SchoolHolidayCountryRow); + } + + /** + * SH3 — `SELECT code, name FROM school_holiday_countries WHERE code = ?`. + * Named `findByCode`, not `find` (the base `EntityRepository.find` name is + * already taken — every other repository in this program follows the same + * `findById`/`findByCode`-style rename, e.g. `CategoriesRepository + * .findById`). + */ + async findByCode(code: string): Promise { + const country = await this.findOne({ code }); + return country ? (toRow(country) as SchoolHolidayCountryRow) : null; + } + + /** + * SH4 — `INSERT OR IGNORE INTO school_holiday_countries (code, name) + * VALUES (?, ?)`. `code` is the table's own PRIMARY KEY (no separate + * `uniques` constraint to name), so a plain `ON CONFLICT DO NOTHING` with + * no column list (the `BudgetCategoryOrderRepository.insertIgnore` + * precedent) covers it. Returns whether a row was actually inserted + * (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows` + * is Kysely's dialect-general affected-row count, 0 when the conflict + * target (an existing `code`) already existed and the insert was + * ignored — the `TripPhotosRepository.insertIgnore` precedent for + * reporting this back to the caller as a boolean instead of swallowing it. + */ + async insertIgnore(country: SchoolHolidayCountryRow): Promise { + const result = await this.kysely() + .insertInto('school_holiday_countries') + .values({ code: country.code, name: country.name }) + .onConflict((oc) => oc.doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** + * SH6 — `DELETE FROM school_holiday_countries WHERE code = ?`. The legacy + * caller (`SchoolHolidaysService.deleteCountry`) always pre-checks + * existence (SH3, via `country()`) and the absence of any referencing + * region (SH5) inside the same transaction before calling this, so the + * affected-row count is never consulted — matching that, this returns + * `void`, not a count. + */ + async remove(code: string): Promise { + await this.nativeDelete({ code }); + } +} + +interface SchoolHolidayCountriesKyselyDB { + school_holiday_countries: { code: string; name: string }; +} diff --git a/server/src/db/repositories/SchoolHolidayPeriods.repository.ts b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts new file mode 100644 index 000000000..0fe2b9838 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts @@ -0,0 +1,62 @@ +import type { SchoolHolidayPeriod } from '@trek/shared'; +import type { SchoolHolidayPeriods } from '../entities/SchoolHolidayPeriods.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `school_holiday_periods`'s single-table read/write shape (Plan 3f Task 2, + * SH8/SH10/SH11/SH15). No `AssertRowKeys` pairing here (unlike this + * program's usual `toRow(entity) as Row` shape): the legacy SH8 statement + * itself renames `start_date`/`end_date` to `startDate`/`endDate` via SQL + * aliasing (the API's own `SchoolHolidayPeriod` shape, `@trek/shared`), so + * the equivalent here is a plain field-renaming `.map()` after a narrowed + * `find` (the `McpTokensRepository.listByUserAndKind` precedent for a + * `fields`-narrowed-then-mapped projection), not a row interface whose keys + * are expected to match the entity's column names 1:1. + */ +export class SchoolHolidayPeriodsRepository extends TrekRepository { + /** + * SH8 — `SELECT name, start_date AS startDate, end_date AS endDate FROM + * school_holiday_periods WHERE region_id = ? ORDER BY start_date, + * end_date, name`. `id`/`region_id` are not selected — the legacy + * projection never returns them — so `fields` narrows to exactly the + * three legacy columns; none of the three is a `persist(false)` shadow + * scalar (that trap only applies to `region_id`, not selected here), so + * narrowing is safe. + */ + async listForRegion(region_id: number): Promise { + const periods = await this.find( + { region: region_id }, + { fields: ['name', 'start_date', 'end_date'], orderBy: { start_date: 'asc', end_date: 'asc', name: 'asc' } }, + ); + return periods.map((period) => ({ name: period.name, startDate: period.start_date, endDate: period.end_date })); + } + + /** + * SH10/SH15 — `DELETE FROM school_holiday_periods WHERE region_id = ?`, + * the replace-all half of `writePeriods` (called from both `createRegion` + * and `updateRegion`, plus `deleteRegion`'s own cleanup) and always + * called from inside the surrounding `uow.transactional` block, same as + * the legacy code. + */ + async deleteForRegion(region_id: number): Promise { + await this.nativeDelete({ region: region_id }); + } + + /** + * SH11 — `INSERT INTO school_holiday_periods (region_id, name, + * start_date, end_date) VALUES (?, ?, ?, ?)`, the legacy `.prepare()` + * -then-loop shape (the ONE such shape in this whole plan) — one batched + * `insertMany` call here instead of N round trips, same end state (every + * period row written), called only from inside the surrounding + * transaction (never on its own). Named `insertPeriods`, not `insertMany` + * (the base `EntityRepository.insertMany` name is already taken). Empty + * `holidays` is a legitimate legacy no-op (the loop over an empty array + * writes nothing) — guarded here rather than handed to `insertMany`, + * matching every other batch-write method in this program + * (`AssignmentParticipantsRepository.insertIgnore`'s precedent). + */ + async insertPeriods(region_id: number, holidays: SchoolHolidayPeriod[]): Promise { + if (holidays.length === 0) return; + await this.insertMany(holidays.map((holiday) => ({ region: region_id, name: holiday.name, start_date: holiday.startDate, end_date: holiday.endDate }))); + } +} diff --git a/server/src/db/repositories/SchoolHolidayRegions.repository.ts b/server/src/db/repositories/SchoolHolidayRegions.repository.ts new file mode 100644 index 000000000..3d4233eb0 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayRegions.repository.ts @@ -0,0 +1,140 @@ +import { collateNoCase, columnIncrementedBy } from '../dialect/sql-functions'; +import type { SchoolHolidayRegions } from '../entities/SchoolHolidayRegions.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `school_holiday_regions` row as the API emits it, MINUS the synthesized + * `country || '-MANUAL-' || id AS code` column (SH2/SH7) — computed in + * `SchoolHolidaysService` from `country`/`id` instead of in SQL: it is pure, + * derived data with no WHERE clause anywhere ever filtering on it (unlike + * `VacayHolidayCalendars.region`, which stores this same string as a plain + * value to MATCH against), so a JS template string after the read is exactly + * as correct as the legacy SQL concatenation and needs no `concat()` dialect + * helper. + */ +export interface SchoolHolidayRegionRow { + id: number; + country: string; + name: string; + revision: number; +} + +const _schoolHolidayRegionRowKeys: AssertRowKeys = true; + +/** + * `school_holiday_regions`'s single-table read/write shape (Plan 3f Task 2, + * SH2/SH5/SH7/SH9/SH12/SH13/SH16). `country` is a `persist(false)` shadow + * scalar of the real relation (`countryRef`, `.manyToOne(SchoolHolidayCountries) + * .ref().joinColumn('country')`) — §14 of the plan's inventory flagged this + * column as possibly FK-less; reading the entity directly (not assumed) shows + * it IS FK-backed, just under the inverted naming this table's entity + * generator produced (the relation carries the `Ref` suffix, the bare column + * name is the plain-scalar shadow — the opposite of `SchoolHolidayPeriods + * .region`/`.region_id`). A plain, unrestricted `find`/`findOne` (no `fields` + * narrowing) hydrates `country` from the physical column exactly like any + * other persist(false) shadow twin in this program (`TripPhotosRepository + * .listTripIdsSharedForPhoto`'s docstring names the same trap for a narrowed + * read) — every read below reads the whole row, so this never bites here. + * Writes go through `countryRef` (the relation property), never `country` + * itself (`persist(false)` silently drops a direct write), matching every + * other shadow-FK write in this program (`DayNotesRepository.create`'s + * `day: input.day_id` precedent). + */ +export class SchoolHolidayRegionsRepository extends TrekRepository { + /** SH2 — `SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id` (code column computed by the service, see this file's own docstring). */ + async list(): Promise { + const regions = await this.find({}, { orderBy: { name: 'asc', id: 'asc' } }); + return regions.map((region) => toRow(region) as SchoolHolidayRegionRow); + } + + /** + * SH7 — same base text as SH2, single row, `WHERE id = ?`, no `ORDER BY`. + * Named `findById`, not `find` (the base `EntityRepository.find` name is + * already taken). + */ + async findById(id: number): Promise { + const region = await this.findOne({ id }); + return region ? (toRow(region) as SchoolHolidayRegionRow) : null; + } + + /** + * SH5 — `SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1` + * (`deleteCountry`'s "no region still references this country" guard). + * Also the additive consumer Plan 3f Task 5 (`vacay.service.ts + * #validateManualRegion`, VC45) calls, per the plan's own cross-domain + * sequencing note — this repository lands first, so Task 5 gets a + * populated method instead of leaving its own read raw a beat longer. + */ + async existsForCountry(country: string): Promise { + const row = await this.findOne({ country }, { fields: ['id'] }); + return row !== null; + } + + /** + * SH9 (`checkName`'s duplicate-name guard) — `SELECT id FROM + * school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE + * AND id != ?`. Uses `collateNoCase` (Plan 3f Task 0, R9) as the filter + * KEY, the same `{ [lower(platform, col)]: value }` shape + * `Users.repository.ts#findIdByUsernameCI` uses for its own + * case-insensitive collision check — NOT `lower()`: the two helpers are + * proven to agree on every input tested (Task 0's report, SQLF-061) but + * are textually different SQL constructs matching two textually different + * legacy statements, and the brief for this task is explicit that + * `collateNoCase` is the one to use here, not a substitution. + * `collateNoCase` renders the COLLATE on the column side (`name COLLATE + * NOCASE = ?`) where the legacy statement spells it on the value side + * (`name = ? COLLATE NOCASE`) — row-identical (SQLF-060), not just + * assumed, per Task 0's own concern note for this task. + */ + async findIdByNameCI(country: string, name: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { country, [collateNoCase(platform, 'name')]: name, id: { $ne: excludeId } }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** + * SH12 — `INSERT INTO school_holiday_regions (country, name) VALUES (?, + * ?)`. `revision` is omitted from the write, same as the legacy column + * list — the entity's own default (`p.integer().default(1)`, verified + * against `Migration20200101033600_create_school_holiday_countries.ts`'s + * `revision INTEGER NOT NULL DEFAULT 1`) applies, matching the legacy + * column default exactly. Named `insertRegion`, not `insert` (the base + * `EntityRepository.insert` name is already taken). + */ + async insertRegion(country: string, name: string): Promise { + return this.insert({ countryRef: country, name }); + } + + /** + * SH13 (`updateRegion`) — `UPDATE school_holiday_regions SET name = ?, + * revision = revision + 1 WHERE id = ? AND revision = ?`. A genuine + * optimistic-concurrency ("lost update") guard, not a cosmetic detail: + * this method returns the raw affected-row count (0 or 1) and does NOT + * throw or interpret it itself — `nativeUpdate`'s own return value IS + * that count, so nothing here can silently swallow a 0-row result the + * way an ORM `nativeUpdate` wrapped in a helper that only returns the + * updated entity would. `SchoolHolidaysService.updateRegion` is the one + * that turns a 0 into `ConflictException`. `columnIncrementedBy` + * (`InviteTokensRepository.incrementUsedCount`'s precedent) for the + * sibling-column `revision + 1` — never a hand-spelled `raw()` call here. + */ + async updateWithRevision(id: number, expectedRevision: number, name: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeUpdate({ id, revision: expectedRevision }, { name, revision: columnIncrementedBy(platform, 'revision', 1) }); + } + + /** + * SH16 — `DELETE FROM school_holiday_regions WHERE id = ?`. The caller + * (`deleteRegion`) already re-read the region and checked its revision + * inside the same transaction before calling this, so — same as + * `SchoolHolidayCountriesRepository.remove` — the affected-row count is + * never consulted. + */ + async remove(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/Settings.repository.ts b/server/src/db/repositories/Settings.repository.ts new file mode 100644 index 000000000..0a65440a5 --- /dev/null +++ b/server/src/db/repositories/Settings.repository.ts @@ -0,0 +1,92 @@ +import type { Settings } from '../entities/Settings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `settings` row (the per-user key/value table) as the API emits it. */ +export interface SettingRow { + id: number; + user_id: number; + key: string; + value: string | null; +} + +const _settingRowKeys: AssertRowKeys = true; + +export class SettingsRepository extends TrekRepository { + /** + * `SELECT key, value FROM settings WHERE user_id = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`) — a "rows out" + * read, converted via `toRow` and discarded. + */ + async getForUser(userId: number): Promise { + const rows = await this.find({ user: userId }); + return rows.map((row) => toRow(row) as SettingRow); + } + + /** + * `SELECT value FROM settings WHERE user_id = ? AND key = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — supersedes Plan 3a's I1 — see `Users.repository.ts`'s + * class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this + * filter is `(user, key)` — `Settings`'s PK is the surrogate `id`, not + * this composite — so it was never PK-only and always re-queried even + * before this ruling; `disableIdentityMap: true` is applied uniformly + * regardless, matching every other read in this repository. + * `SETTINGSREPO-012`/`013` pin the always-fresh-read property directly (a + * raw UPDATE/DELETE on the same row, then `getOne` in the same request, + * sees it) rather than assume it. + */ + async getOne(userId: number, key: string): Promise { + const row = await this.findOne({ user: userId, key }); + return row ? (toRow(row) as SettingRow) : null; + } + + /** + * `INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) + * ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value` + * + * The composite conflict target is the relation field (`user`), never its + * `persist(false)` scalar twin (`user_id`) — same rule as every other + * `create`/`upsert` call in this migration (D4's defaults rule): the + * relation is the one column MikroORM actually writes, the twin is filled + * by a subsequent read. `Settings`'s own `uniques: [{ properties: ['user', + * 'key'] }]` (`Settings.entity.ts`, generated off the schema's inline + * `UNIQUE(user_id, key)` by the `fix(db)` commit that precedes this one) + * names that same relation field — `Settings.repository.test.ts` asserts + * both the schema-level unique index and the entity metadata. + * + * `onConflictFields` is passed explicitly here for CLARITY at the call + * site, not because `em.upsert` needs it to find the conflict target: the + * `uniques` entry above is fidelity to the schema first, but as a + * consequence it is also consumable — `em.upsert` infers the same + * `(user, key)` target from it alone when `onConflictFields` is omitted + * (`getWhereCondition` in `@mikro-orm/core/utils/upsert-utils.js` matches a + * `uniques` entry's `properties` against the payload's own property-named + * keys). `SETTINGSREPO-014` proves that inference directly, independent of + * this method's explicit option. + * + * Only `value` is ever updated on conflict, matching the legacy statement's + * `DO UPDATE SET value = excluded.value` exactly (no other column, no + * `updated_at` — the `settings` table has none). + */ + async upsertForUser(userId: number, key: string, value: string): Promise { + await this.upsert({ user: userId, key, value }, { onConflictFields: ['user', 'key'], onConflictAction: 'merge' }); + } + + /** + * Plan 3j Task 2 — `plugin-runtime.service.ts#uninstall`'s `deleteData` branch + * (PR39): `` DELETE FROM settings WHERE key LIKE 'plugin:${id}:%' ``. No `user` + * filter — this deletes a plugin's settings across EVERY user, unlike every + * other method on this repository, which is why it takes a bare key-prefix + * `$like` pattern rather than the `(user, key)` shape the rest of the class + * uses. `prefix` is caller-built (`` `plugin:${id}:` ``) and bound as a plain + * parameter — never interpolated into SQL text. + */ + async deleteByKeyPrefix(prefix: string): Promise { + return await this.nativeDelete({ key: { $like: `${prefix}%` } }); + } +} diff --git a/server/src/db/repositories/ShareTokens.repository.ts b/server/src/db/repositories/ShareTokens.repository.ts new file mode 100644 index 000000000..e2134a388 --- /dev/null +++ b/server/src/db/repositories/ShareTokens.repository.ts @@ -0,0 +1,170 @@ +import type { ShareTokens } from '../entities/ShareTokens.entity'; +import { currentTimestampKysely } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `share_tokens` — public trip-share links (Plan 3h Task 6). `trip_id` and + * `created_by` are `persist(false)` shadow mirrors of the `trip`/`createdByRef` + * relations (`ShareTokens.entity.ts`), so every READ here goes through + * `this.db_()` (Kysely), never a bare `qb().select(['trip_id', ...])` — the + * `CollectionPlacesRepository`/`DayAssignmentsRepository.assignmentWithPlaceSelect` + * class-docstring trap applies here too. WRITES go through the MikroORM + * entity API (`insert`/`nativeUpdate`/`nativeDelete`), using the entity's own + * relation property names (`trip`/`createdByRef`) for the two FK columns — + * the `DocumentConnectionsRepository.insertConnection` precedent. + */ +export interface ShareTokenRow { + id: number; + trip_id: number; + token: string; + created_by: number; + share_map: number | null; + share_bookings: number | null; + share_packing: number | null; + share_budget: number | null; + share_collab: number | null; + created_at: string | null; + expires_at: string | null; + share_travel_only: number; + share_hide_images: number; +} + +interface ShareTokensKyselyDB { + share_tokens: ShareTokenRow; +} + +export class ShareTokensRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + /** + * SH1 (`createOrUpdate`, TX) — `SELECT token FROM share_tokens WHERE + * trip_id = ?`. + */ + async findTokenByTrip(trip_id: number | string): Promise<{ token: string } | undefined> { + return await this.db_().selectFrom('share_tokens').select('token').where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * SH2 (`createOrUpdate`, TX, existing row) — `UPDATE share_tokens SET + * share_map=?, share_bookings=?, share_packing=?, share_budget=?, + * share_collab=?, share_travel_only=?, share_hide_images=?, expires_at=? + * WHERE trip_id=?`. + */ + async updateFlagsByTrip(trip_id: number | string, data: { + share_map: number; + share_bookings: number; + share_packing: number; + share_budget: number; + share_collab: number; + share_travel_only: number; + share_hide_images: number; + expires_at: string; + }): Promise { + await this.nativeUpdate({ trip: trip_id as number }, data); + } + + /** + * SH3 (`createOrUpdate`, TX, new row) — `INSERT INTO share_tokens + * (trip_id, token, created_by, share_map, share_bookings, share_packing, + * share_budget, share_collab, share_travel_only, share_hide_images, + * expires_at) VALUES (...)`. `token` is + * generated by the caller (`crypto.randomBytes(24)`), passed straight + * through. + */ + async insertNew(data: { + trip_id: number | string; + token: string; + created_by: number; + share_map: number; + share_bookings: number; + share_packing: number; + share_budget: number; + share_collab: number; + share_travel_only: number; + share_hide_images: number; + expires_at: string; + }): Promise { + await this.insert({ + trip: data.trip_id as number, + token: data.token, + createdByRef: data.created_by, + share_map: data.share_map, + share_bookings: data.share_bookings, + share_packing: data.share_packing, + share_budget: data.share_budget, + share_collab: data.share_collab, + share_travel_only: data.share_travel_only, + share_hide_images: data.share_hide_images, + expires_at: data.expires_at, + }); + } + + /** SH4 (`get`) — `SELECT * FROM share_tokens WHERE trip_id = ?`. */ + async findRawByTrip(trip_id: number | string): Promise { + return await this.db_().selectFrom('share_tokens').selectAll().where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * SH5 (`remove`) — `DELETE FROM share_tokens WHERE trip_id = ?`. + * **DISTINCT from `deleteByCreator` (UC6)** — a different predicate on the + * same table, never conflated into one method with an optional parameter. + */ + async deleteByTrip(trip_id: number | string): Promise { + await this.nativeDelete({ trip: trip_id as number }); + } + + /** + * The `token = ?` and `(expires_at IS NULL OR expires_at > )` + * predicate SH6, SH17 and `platform.routes.ts`'s R1-converted pre-init + * `servePhoto` fallback ALL share — built exactly once here, never + * re-derived at any of the three call sites (R5). `token` is compared + * with a plain `=` — no `LIKE`, no case-folding, ever (R5's own + * "the opposite of rule 18" framing). `currentTimestampKysely` (not a + * hand-written `datetime('now')` literal): SQLite's bare `CURRENT_TIMESTAMP` + * keyword renders in the SAME `YYYY-MM-DD HH:MM:SS` UTC text `datetime('now')` + * does, so reusing the existing helper is byte-equivalent to the legacy + * predicate, not an approximation of it. + */ + private validTokenQuery(token: string) { + const platform = this.getEntityManager().getPlatform(); + return this.db_() + .selectFrom('share_tokens') + .where('token', '=', token) + .where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', currentTimestampKysely(platform))])); + } + + /** SH6 (`getSharedTripData`) — the full row, valid-token predicate above. */ + async findValidByToken(token: string): Promise { + return await this.validTokenQuery(token).selectAll().executeTakeFirst(); + } + + /** + * SH17 (`getSharedPlacePhotoKey`) — `trip_id, share_map, + * share_hide_images` only, SAME valid-token predicate as SH6/R1. + */ + async findTripAndShareMapByToken(token: string): Promise<{ trip_id: number; share_map: number | null; share_hide_images: number } | undefined> { + return await this.validTokenQuery(token).select(['trip_id', 'share_map', 'share_hide_images']).executeTakeFirst(); + } + + /** + * R1 (`platform.routes.ts`'s pre-init `servePhoto` share-token fallback, + * SV-PLATFORM1) — `trip_id` only, the SAME valid-token predicate SH6/SH17 + * use, built once here and reused, not re-derived at the pre-init call + * site. + */ + async findTripIdByToken(token: string): Promise<{ trip_id: number } | undefined> { + return await this.validTokenQuery(token).select(['trip_id']).executeTakeFirst(); + } + + /** + * UC6 (`user-cleanup.service.ts`'s GDPR erasure) — `DELETE FROM + * share_tokens WHERE created_by = ?`. **DISTINCT from `deleteByTrip` + * (SH5)** — deletes every link a user CREATED, across every trip, + * regardless of who owns those trips, not the links on one trip. + */ + async deleteByCreator(userId: number): Promise { + await this.nativeDelete({ createdByRef: userId }); + } +} diff --git a/server/src/db/repositories/Tags.repository.ts b/server/src/db/repositories/Tags.repository.ts new file mode 100644 index 000000000..8b6f5094e --- /dev/null +++ b/server/src/db/repositories/Tags.repository.ts @@ -0,0 +1,275 @@ +import type { Tags } from '../entities/Tags.entity'; +import { findOwnedByUser, listForOwner } from './_shared/owned-lookup'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `tags` row as the API emits it. */ +export interface TagRow { + id: number; + user_id: number; + name: string; + color: string | null; + created_at: string | null; +} + +const _tagRowKeys: AssertRowKeys = true; + +/** + * One tag attached to one place, from `QueryHelpersService.loadTagsByPlaceIds` + * (QH1). `user_id` is present only on the non-compact projection — `listForPlaces` + * selects a narrower column set when `compact` is set, so a compact row never + * carries the key at all (matching the legacy's destructuring, which drops it + * rather than nulling it). + */ +export interface TagForPlaceRow { + place_id: number; + id: number; + user_id?: number; + name: string; + color: string | null; + created_at: string | null; +} + +/** + * `place_tags` (`place_id`, `tag_id`, `PRIMARY KEY (place_id, tag_id)`, + * `Migration20200101000000_baseline_schema.ts:195-199` — verified against + * the migration, not assumed) as `Places.entity.ts`'s own `place_tags` + * many-to-many relation declares it — a PLAIN pivot table + * (`p.manyToMany(Tags).pivotTable('place_tags')...`) with no owning entity of + * its own (unlike `PackingItems`' `packing_item_contributors`, which has a + * `pivotEntity`). Neither `em.upsert` nor `qb()` can target a table with no + * entity mapping — both are bound to exactly one mapped entity — so + * `TrekRepository.kysely()` (D3's sanctioned next escape hatch) is the only + * API that reaches it. + */ +interface PlaceTagsKyselyDB { + place_tags: { place_id: number; tag_id: number }; +} + +/** + * `place_tags` joined to `places` for TP46 (`trips.service.ts::copy`'s + * place-tags read) — `trip_id` widened to `number | string` (D4's T5 escape + * hatch, `TripsRepository.findAccessible`'s precedent) so the raw-bind seam + * matches `TripsService.copy`'s own `sourceTripId: string | number` + * parameter. + */ +interface PlaceTagsForTripKyselyDB { + place_tags: { place_id: number; tag_id: number }; + places: { id: number; trip_id: number | string }; +} + +export class TagsRepository extends TrekRepository { + /** + * `SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC`, via the shared + * `listForOwner` helper (`tags.user_id` is `NOT NULL`, a strictly-owned + * entity — see `_shared/owned-lookup.ts`). + */ + async listByUser(userId: number): Promise { + const tags = await listForOwner(this, 'user', userId, 'name'); + return tags.map((tag) => toRow(tag) as TagRow); + } + + /** + * `SELECT * FROM tags WHERE id = ? AND user_id = ?`, via the shared + * `findOwnedByUser` helper — an ownership check (RPC/MCP writes re-verify + * with this before mutating), not a re-select following this repository's + * own write (`createTag`/`patch` refresh/return their own entity + * directly). + * + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B), per `findOwnedByUser`'s own + * docstring — this is a "rows out" read, converted via `toRow` and + * discarded. + */ + async findByIdAndUser(id: number, userId: number): Promise { + const tag = await findOwnedByUser(this, id, 'user', userId); + return tag ? (toRow(tag) as TagRow) : null; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)`. The + * `#10b981` defaulting stays in `TagsService` (D4's defaults rule) — this + * writes exactly what it is given. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`, same reasoning as + * `CategoriesRepository.createCategory`: `flush()` commits the *whole* + * unit of work of the request's `EntityManager`, not just this row. + * Followed by a read-back (`findOne({ id })`, `disableIdentityMap: true` + * by the base class's default): the returned row carries the generated + * `id` and the `defaultRaw` `created_at` the same way a legacy + * INSERT-then-reselect pair did. + */ + async createTag(input: { user_id: number; name: string; color: string }): Promise { + const id = await this.insert({ + user: input.user_id, + name: input.name, + color: input.color, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createTag: read-back after insert found no row'); + } + return toRow(inserted) as TagRow; + } + + /** + * `UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) + * WHERE id = ?`, re-selected — no `user_id` filter, matching the legacy + * statement exactly. `nativeUpdate` only writes the keys present on + * `changes` (the entity-API equivalent of `COALESCE` against a bind + * parameter), so `TagsService` omits a field entirely, rather than + * passing it as `undefined`, to leave it untouched — reproducing the + * legacy `|| null` coalesce-away-empty-string behaviour. Returns `null` + * when no row matches `id`, mirroring the legacy re-select's `undefined`. + * + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on `CategoriesRepository.patch`, applied + * here identically): "convert them, don't complete the sweep on the + * read."** `nativeUpdate` writes the given columns directly, with no ORM + * change-set diff against an in-memory snapshot — that removes the whole + * class of staleness `refresh: true` existed to guard against (Task 3 + * review, Important 1: an untouched column reading stale, a patch + * silently dropped because it happened to match a stale in-memory value, + * and a patch after `remove()` fabricating a row), not just the one B1 + * shape the rest of the program's `disableIdentityMap: true` ruling + * closes. When `changes` is non-empty, `nativeUpdate`'s own affected-row + * count IS the existence check (0 ⇒ `null`, no separate read); when + * `changes` is empty, a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh read, not + * the entity `assign` used to mutate in place. + */ + async patch(id: number, changes: { name?: string; color?: string }): Promise { + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + const tag = await this.findOne({ id }); + return tag ? (toRow(tag) as TagRow) : null; + } + + /** `DELETE FROM tags WHERE id = ?`. Returns the affected row count. */ + async remove(id: number): Promise { + return this.nativeDelete({ id }); + } + + /** + * `SELECT t.*, pt.place_id FROM tags t JOIN place_tags pt ON t.id = pt.tag_id + * WHERE pt.place_id IN (${…})` (`query-helpers.service.ts:41-45`, QH1) — + * ONE method, TWO projections (Plan 3c Task 1 ruling): `compact` selects + * `{id, name, color, created_at, place_id}`; the non-compact path selects + * every `tags` column except the join key (`{id, user_id, name, color, + * created_at, place_id}`), exactly the legacy's `t.*` minus `place_id`. + * + * Joined through `t.place_tags_inverse` (the inverse side of `Places.place_tags`, + * a many-to-many over the `place_tags` pivot table): selecting the joined + * place's own `id` as `place_id` is the same value the legacy's `pt.place_id` + * read off the pivot row directly (the pivot's `place_id` column IS the + * places row it points at), so this is not an approximation. + * + * Empty `placeIds` short-circuits before any query, matching the legacy + * service method. + */ + async listForPlaces(placeIds: number[], options?: { compact?: boolean }): Promise { + if (placeIds.length === 0) return []; + if (options?.compact) { + return this.qb('t') + .join('t.place_tags_inverse', 'p') + .select(['t.id', 't.name', 't.color', 't.created_at', 'p.id as place_id']) + .where({ 'p.id': { $in: placeIds } }) + .execute('all', false); + } + return this.qb('t') + .join('t.place_tags_inverse', 'p') + .select(['t.id', 't.user', 't.name', 't.color', 't.created_at', 'p.id as place_id']) + .where({ 'p.id': { $in: placeIds } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService`) — additive methods. + // --------------------------------------------------------------------------- + + /** + * PL2 (`PlacesService.tagsOnTrip`) — `` SELECT id, user_id FROM tags WHERE + * id IN (${…}) ``, the ownership half of tag scoping: the caller + * (`tagsOnTrip`) intersects these rows' `user_id` against the trip's + * roster, and an id belonging to nobody on the roster drops silently. + * `$in` for the dynamic list (rule 17b). + */ + async findByIds(ids: number[]): Promise<{ id: number; user_id: number }[]> { + if (ids.length === 0) return []; + // `t.user` (unaliased, not separately joined) resolves to the physical + // `user_id` column and comes back keyed as `user_id` on the raw driver + // row — `TripsRepository.findAccessible`'s `.select(['t.id', 't.user', + // 't.currency'])` establishes the same shape. + return this.qb('t') + .select(['t.id', 't.user']) + .where({ id: { $in: ids } }) + .execute<{ id: number; user_id: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService.create`/`applyUpdate`, PL5/PL12/PL13) — + // `place_tags` writes. `PlaceTagsKyselyDB` above documents why Kysely. + // --------------------------------------------------------------------------- + + /** + * PL5/PL13 — `INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES + * (?, ?)`, the legacy statement run once per tag id. One batched Kysely + * insert with `onConflict((oc) => oc.columns(['place_id', 'tag_id']) + * .doNothing())` on the pivot's real composite `PRIMARY KEY (place_id, + * tag_id)` — SQLite's `ON CONFLICT (...) DO NOTHING` (supported since + * SQLite 3.24, well within better-sqlite3's bundled version) is the + * upsert-API-less equivalent of `INSERT OR IGNORE` for a table with no + * entity mapping. Same end state as the legacy per-row loop (every + * genuinely new pair inserted, every duplicate silently skipped); the + * caller (`PlacesService.tagsOnTrip`) always hands an already + * roster-scoped, deduplicated tag id list — the empty-array guard here is + * defensive, matching every other batch-write method in this program. + */ + async insertIgnore(place_id: number, tag_ids: number[]): Promise { + if (tag_ids.length === 0) return; + await this.kysely() + .insertInto('place_tags') + .values(tag_ids.map((tag_id) => ({ place_id, tag_id }))) + .onConflict((oc) => oc.columns(['place_id', 'tag_id']).doNothing()) + .execute(); + } + + /** PL12 — `DELETE FROM place_tags WHERE place_id = ?` (the replace-all half of an update). */ + async deleteForPlace(place_id: number): Promise { + await this.kysely() + .deleteFrom('place_tags') + .where('place_id', '=', place_id) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP46 (`trips.service.ts::copy`'s place-tags read) — `SELECT pt.* FROM + * place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?`, + * every `place_tags` pair for every place in a trip. Kysely (the pivot has + * no entity mapping of its own, `PlaceTagsKyselyDB`'s own docstring); TP47 + * (the `INSERT OR IGNORE` write half) reuses {@link insertIgnore} above, + * called once per row with a single-element `tag_ids` array — the same + * shape the legacy per-row loop wrote in. + */ + async listPlaceTagsForTrip(trip_id: number | string): Promise<{ place_id: number; tag_id: number }[]> { + return await this.kysely() + .selectFrom('place_tags as pt') + .innerJoin('places as p', 'p.id', 'pt.place_id') + .select(['pt.place_id', 'pt.tag_id']) + .where('p.trip_id', '=', trip_id) + .execute(); + } +} diff --git a/server/src/db/repositories/TodoCategoryAssignees.repository.ts b/server/src/db/repositories/TodoCategoryAssignees.repository.ts new file mode 100644 index 000000000..3dd941076 --- /dev/null +++ b/server/src/db/repositories/TodoCategoryAssignees.repository.ts @@ -0,0 +1,79 @@ +import type { TodoCategoryAssignees } from '../entities/TodoCategoryAssignees.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** TD11/TD14's joined projection — the assignee row plus the display fields the picker needs. */ +export interface TodoCategoryAssigneeRow { + user_id: number; + username: string; + avatar: string | null; +} + +interface TodoCategoryAssigneesKyselyDB { + todo_category_assignees: { id: number; trip_id: number; category_name: string; user_id: number }; + users: { id: number; username: string; avatar: string | null }; +} + +interface TodoCategoryAssigneesInsertKyselyDB { + todo_category_assignees: { trip_id: number | string; category_name: string; user_id: number }; +} + +/** + * `todo_category_assignees` — which trip members are pinned to a to-do + * category, driving the picker `TodoService.getCategoryAssignees`/ + * `updateCategoryAssignees` render. Kysely throughout: `trip_id`/`user_id` + * are `persist(false)` relation mirrors (the program-wide trap), and the + * `INSERT OR IGNORE` write has no single `em.upsert`-expressible conflict + * target worth reaching for over Kysely's `ON CONFLICT DO NOTHING` (the + * `BudgetItemMembersRepository.insertIgnore` precedent) — the entity's own + * three-column unique (`trip`, `category_name`, `user`) is the constraint + * SQLite's `INSERT OR IGNORE` already ignores a conflict against. + */ +export class TodoCategoryAssigneesRepository extends TrekRepository { + /** TD11 (`getCategoryAssignees`) — `SELECT tca.category_name, tca.user_id, u.username, u.avatar FROM todo_category_assignees tca JOIN users u ON tca.user_id = u.id WHERE tca.trip_id = ?`. */ + async listForTrip(trip_id: number | string): Promise<(TodoCategoryAssigneeRow & { category_name: string })[]> { + return await this.kysely() + .selectFrom('todo_category_assignees as tca') + .innerJoin('users as u', 'u.id', 'tca.user_id') + .select(['tca.category_name', 'tca.user_id', 'u.username', 'u.avatar']) + .where('tca.trip_id', '=', trip_id as number) + .execute(); + } + + /** TD12 (`updateCategoryAssignees`) — `DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?` (replace-all before a re-insert). */ + async deleteForCategory(trip_id: number | string, category_name: string): Promise { + await this.kysely() + .deleteFrom('todo_category_assignees') + .where('trip_id', '=', trip_id as number) + .where('category_name', '=', category_name) + .execute(); + } + + /** + * TD13 (`updateCategoryAssignees`, looped, roster-filtered) — `INSERT OR + * IGNORE INTO todo_category_assignees (trip_id, category_name, user_id) + * VALUES (?, ?, ?)`. The service filters `userIds` against + * `DatabaseService.rosterUserIds` before calling this once per surviving + * id — dropped rather than rejected: a copied trip carries assignee ids + * across before its members exist, and a 400 would make the picker + * unusable there. Kept exactly (`TodoService.updateCategoryAssignees`'s + * own comment). + */ + async insertIgnore(trip_id: number | string, category_name: string, user_id: number): Promise { + await this.kysely() + .insertInto('todo_category_assignees') + .values({ trip_id: trip_id as number, category_name, user_id }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** TD14 (`updateCategoryAssignees`'s post-write re-select) — `SELECT tca.user_id, u.username, u.avatar FROM todo_category_assignees tca JOIN users u ON tca.user_id = u.id WHERE tca.trip_id = ? AND tca.category_name = ?`. */ + async listForCategory(trip_id: number | string, category_name: string): Promise { + return await this.kysely() + .selectFrom('todo_category_assignees as tca') + .innerJoin('users as u', 'u.id', 'tca.user_id') + .select(['tca.user_id', 'u.username', 'u.avatar']) + .where('tca.trip_id', '=', trip_id as number) + .where('tca.category_name', '=', category_name) + .execute(); + } +} diff --git a/server/src/db/repositories/TodoItems.repository.ts b/server/src/db/repositories/TodoItems.repository.ts new file mode 100644 index 000000000..3c6d023c8 --- /dev/null +++ b/server/src/db/repositories/TodoItems.repository.ts @@ -0,0 +1,277 @@ +import type { TodoItems } from '../entities/TodoItems.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; +import { columnRef, currentTimestamp, nowMinusHours } from '../dialect/sql-functions'; + +/** A bare `todo_items` row — every scalar column, incl. the two `persist(false)` relation mirrors (`trip_id`, `assigned_user_id`). */ +export interface TodoItemRow { + id: number; + trip_id: number; + name: string; + checked: number | null; + category: string | null; + sort_order: number | null; + due_date: string | null; + description: string | null; + assigned_user_id: number | null; + priority: number | null; + created_at: string | null; + reminded_at: string | null; +} + +const _todoItemRowKeys: AssertRowKeys = true; + +interface TodoItemsKyselyDB { + todo_items: TodoItemRow; +} + +/** The insert-only shape for `insertItem` (TD3) — omits `id`/`created_at`/`reminded_at` (autoincrement / `DEFAULT CURRENT_TIMESTAMP` / never written here). */ +interface TodoItemsInsertKyselyDB { + todo_items: { + trip_id: number | string; name: string; checked: number; category: string | null; sort_order: number; + due_date: string | null; description: string | null; assigned_user_id: number | null; priority: number; + }; +} + +/** The insert-only shape for `insertCopy` (TP73) — a distinct column set from {@link TodoItemsRepository.insertItem}'s: no `assigned_user_id` parameter at all (always written `NULL`, never taken from the caller). */ +interface TodoItemsCopyInsertKyselyDB { + todo_items: { + trip_id: number | string; name: string; checked: number; category: string | null; sort_order: number | null; + due_date: string | null; description: string | null; assigned_user_id: null; priority: number | null; + }; +} + +/** `isoDate` + 1 calendar day, in UTC — turns {@link TodoItemsRepository.listDueForReminder}'s inclusive cutoff into an exclusive `$lt` bound (L2, task-7-review.md). */ +function dayAfter(isoDate: string): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().slice(0, 10); +} + +/** + * `todo_items` — Kysely throughout: `trip_id`/`assigned_user_id` are + * `persist(false)` relation mirrors (the program-wide bare-`select`/`fields` + * drop trap — `TripFilesRepository`'s class docstring, restated at Task 2's + * `BudgetItemsRepository`/Task 6's TC-form), avoided the same way here by + * going through Kysely for every statement rather than the QueryBuilder. + */ +export class TodoItemsRepository extends TrekRepository { + /** TD1 (`listItems`) — `SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC`. */ + async listForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('todo_items') + .selectAll() + .where('trip_id', '=', trip_id as number) + .orderBy('sort_order', 'asc') + .orderBy('created_at', 'asc') + .execute(); + } + + /** TD2 (`createItem`) — `SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('todo_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.max ?? null; + } + + /** + * TD3 (`createItem`) — `INSERT INTO todo_items (trip_id, name, checked, + * category, sort_order, due_date, description, assigned_user_id, priority) + * VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)`. Returns the new row's id (TD4's + * re-select key). Named `insertItem`, not `create`/`insert` — both collide + * with `TrekRepository`/`EntityRepository`'s own same-named methods (a + * different signature: an unpersisted-entity builder, not an INSERT). + */ + async insertItem(row: { + trip_id: number | string; name: string; category: string | null; sort_order: number; + due_date: string | null; description: string | null; assigned_user_id: number | null; priority: number; + }): Promise { + const result = await this.kysely() + .insertInto('todo_items') + .values({ ...row, checked: 0 }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** TD4/TD7 (`createItem`/`updateItem`'s post-write re-select) — `SELECT * FROM todo_items WHERE id = ?` (no trip filter — the caller already knows `id` is in-scope, having just written it). */ + async findById(id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('id', '=', id as number).executeTakeFirst(); + } + + /** + * TD5 (`updateItem`'s trip-scoping guard) — `SELECT * FROM todo_items + * WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the + * program's own gate-level id parsing carry: narrowed from `number | + * string` now that `TodoController.update`/`.remove` parse `:id` once via + * `toRowId` and thread the number down; `trip_id` stays `number | string`, + * a separate, still-accepted carry — see `todo.service.ts`'s own note). + */ + async findInTrip(id: number, trip_id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** TD8 (`deleteItem`'s trip-scoping guard) — `SELECT id FROM todo_items WHERE id = ? AND trip_id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link findInTrip}. */ + async existsInTrip(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('todo_items').select('id').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * TD6 (`updateItem`) — the 7-column presence-sentinel `UPDATE` (R11's + * helper — {@link presenceSet}, copying Task 2's `BudgetItemsRepository + * .update` shape). `name`/`category` keep the legacy `COALESCE(?, col)` + * truthy-wins semantics (the caller passes `present = !!value`, matching + * `data.name || null` / `data.category || null`'s fall-through-on-falsy + * exactly); every other column is a true presence sentinel bound off the + * legacy `bodyKeys` array (`present = bodyKeys.includes('')`). + */ + async update(id: number, write: { + name?: readonly [present: boolean, value: string]; + checked?: readonly [present: boolean, value: number]; + category?: readonly [present: boolean, value: string | null]; + due_date?: readonly [present: boolean, value: string | null]; + description?: readonly [present: boolean, value: string | null]; + assigned_user_id?: readonly [present: boolean, value: number | null]; + priority?: readonly [present: boolean, value: number]; + }): Promise { + const data = presenceSet<{ + name: string; checked: number; category: string | null; due_date: string | null; + description: string | null; assigned_user_id: number | null; priority: number; + }>(write); + if (Object.keys(data).length === 0) return; + await this.kysely().updateTable('todo_items').set(data).where('id', '=', id).execute(); + } + + /** TD9 (`deleteItem`) — `DELETE FROM todo_items WHERE id = ?` (no trip scoping in the statement itself — relies on the caller's prior {@link existsInTrip} gate, matching legacy). `id: number`, same Plan 4 Task 8b narrowing as {@link findInTrip}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('todo_items').where('id', '=', id).execute(); + } + + /** TD10 (`reorderItems`, looped) — `UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */ + async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise { + await this.kysely().updateTable('todo_items').set({ sort_order }).where('id', '=', id as number).where('trip_id', '=', trip_id as number).execute(); + } + + /** TP72 (`TripsService.copy`) — `SELECT * FROM todo_items WHERE trip_id = ?`. */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('trip_id', '=', trip_id as number).execute(); + } + + /** + * TP73 (`TripsService.copy`) — `INSERT INTO todo_items (trip_id, name, + * checked, category, sort_order, due_date, description, assigned_user_id, + * priority) VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)`. `assigned_user_id` is + * hard-coded `NULL` here, never taken from the caller — the copy + * deliberately does not carry the old assignee across, since the new trip + * has no roster yet (`TripsService.copy`'s own comment, kept exactly). + */ + async insertCopy(row: { + trip_id: number | string; name: string; category: string | null; sort_order: number | null; + due_date: string | null; description: string | null; priority: number | null; + }): Promise { + const result = await this.kysely() + .insertInto('todo_items') + .values({ ...row, checked: 0, assigned_user_id: null }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 4 (`ReminderJobsService#todoTick`) — additive. `this.qb()`, + // not Kysely: RJ5's dedup window needs `nowMinusHours` (a raw MikroORM + // `RawQueryFragment` from `sql-functions.ts`, which composes with a + // QueryBuilder/native filter but not with Kysely — the Kysely `sql` tag is + // banned under `src/db/repositories/**`), and the join needs `columnRef` + // to read `trip_id`/`assigned_user_id` (both `persist(false)` mirrors of + // the real `trip`/`assignedUser` relations — a bare select silently drops + // them, per `RoadtripDayBoundariesRepository`'s documented trap). `ti.trip` + // is ALSO the join target here, so it is never itself selected (that + // aliases off the JOINED row's own PK instead of the FK scalar — + // `PlaceRatingsRepository.listForPlaces`'s documented trap); `t.user` is + // safe to select bare (its target, `users`, is not separately joined in + // this query), aliasing to the physical `user_id` column per + // `TripsRepository.findAccessible`'s precedent. + // --------------------------------------------------------------------------- + + /** + * RJ4 (`reminder-jobs.service.ts#todoTick`) — restructured per Task 0's R9 + * ruling. Legacy: `SELECT ti.id, ti.trip_id, ti.name, ti.due_date, + * ti.assigned_user_id, t.title AS trip_title, t.user_id AS trip_owner_id + * FROM todo_items ti JOIN trips t ON t.id = ti.trip_id WHERE ti.checked = 0 + * AND ti.due_date IS NOT NULL AND ti.due_date <> '' AND date(ti.due_date) + * <= date('now', '+' || ? || ' days') AND date(ti.due_date) >= date('now') + * AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 + * hours'))`. + * + * The `date('now', '+' || ? || ' days')`/`date('now')` bounds are + * JS-computed by the caller (`todayDate`/`cutoffDate`, both `Date.UTC`- + * based `YYYY-MM-DD` text — matching SQLite's own UTC `date('now')`, per + * the same reasoning RJ3's restructuring documents) and bound directly + * against `ti.due_date`. `due_date` is an unconstrained `z.string()` on + * the wire, not always canonical `YYYY-MM-DD` text, so `date(ti.due_date)`'s + * normalization is NOT a no-op: a time-bearing due date on the cutoff day + * itself (`2026-07-14T09:00`) sorts lexically AFTER the bare cutoff date + * string, so a plain `$lte: cutoffDate` drops it a day early (L2, + * task-7-review.md). `date(ti.due_date) <= cutoffDate` is reproduced as an + * exclusive `$lt` against the day AFTER cutoff instead of re-adding the + * `date()` wrap (Kysely `sql` is banned under repositories). The + * `datetime('now', '-20 hours')` dedup bound stays genuinely in SQL, via + * `nowMinusHours` (Task 0's R9 helper) — RJ5's brief explicitly calls for + * it, not a JS equivalent. + */ + async listDueForReminder(todayDate: string, cutoffDate: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const rows = await this.qb('ti') + .join('ti.trip', 't') + .select(['ti.id', columnRef(platform, 'ti.trip_id'), 'ti.name', 'ti.due_date', columnRef(platform, 'ti.assigned_user_id'), 't.title', 't.user']) + .andWhere({ checked: 0 }) + .andWhere({ due_date: { $ne: null } }) + .andWhere({ due_date: { $ne: '' } }) + .andWhere({ due_date: { $gte: todayDate } }) + .andWhere({ due_date: { $lt: dayAfter(cutoffDate) } }) + .andWhere({ $or: [{ reminded_at: null }, { reminded_at: { $lte: nowMinusHours(platform, 20) } }] }) + .execute('all', false); + return rows.map((row) => ({ + id: row.id, + trip_id: row.trip_id, + name: row.name, + due_date: row.due_date, + assigned_user_id: row.assigned_user_id, + trip_title: row.title, + trip_owner_id: row.user_id, + })); + } + + /** + * RJ5 (`reminder-jobs.service.ts#todoTick`, looped) — `UPDATE todo_items + * SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?`. Stays AFTER the + * notification send in call order (plan3f-inputs.md correction #8 — a + * documented, not-fixed-by-this-plan at-least-once ordering, unlike + * `notifications.service.ts#respond`'s deliberately claim-then-act shape). + */ + async markReminded(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { reminded_at: currentTimestamp(platform) }); + } +} + +/** {@link TodoItemsRepository.listDueForReminder}'s raw QB row — before the `trip_title`/`trip_owner_id` rename. */ +interface TodoReminderQueryRow { + id: number; + trip_id: number; + name: string; + due_date: string; + assigned_user_id: number | null; + title: string; + user_id: number; +} + +/** {@link TodoItemsRepository.listDueForReminder}'s output row. */ +export interface TodoReminderRow { + id: number; + trip_id: number; + name: string; + due_date: string; + assigned_user_id: number | null; + trip_title: string; + trip_owner_id: number; +} diff --git a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts new file mode 100644 index 000000000..6dfbb54e7 --- /dev/null +++ b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts @@ -0,0 +1,50 @@ +import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** TC1's row — `content_type`/`fetched_at`, the two columns `getFresh` needs. */ +export interface TrekPhotoCacheFreshness { + content_type: string; + fetched_at: number; +} + +export class TrekPhotoCacheMetaRepository extends TrekRepository { + /** TC1 — `SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?`. */ + async findFreshness(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['content_type', 'fetched_at'] }); + return row ? { content_type: row.content_type, fetched_at: row.fetched_at } : null; + } + + /** + * TC2/TC3/TC6 — `DELETE FROM trek_photo_cache_meta WHERE cache_key = ?`, + * the same statement text at three call sites (the TTL-expired branch, the + * object-missing branch, and `sweepExpired`'s stale pass) — one method (D4). + */ + async deleteByCacheKey(cache_key: string): Promise { + return await this.nativeDelete({ cache_key }); + } + + /** + * TC4 (`put`) — `INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, + * content_type, fetched_at) VALUES (?, ?, ?)`. `cache_key` is the table's + * only unique/primary column (`NOT NULL` since Plan 4 Task 8a's + * cache_key-tightening migration — R5's Task 0 finding was that every + * writer already bound a non-null value, so this was a schema tightening, + * not a behaviour change), so it is the sole conflict target, same upsert + * shape as `AppSettingsRepository.setValue`. + */ + async upsertMeta(cache_key: string, content_type: string, fetched_at: number): Promise { + await this.upsert({ cache_key, content_type, fetched_at }, { onConflictFields: ['cache_key'], onConflictAction: 'merge' }); + } + + /** TC5 — `SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?`, `sweepExpired`'s stale-row pass. */ + async listStale(cutoff: number): Promise { + const rows = await this.find({ fetched_at: { $lt: cutoff } }, { fields: ['cache_key'] }); + return rows.map((row) => row.cache_key).filter((key): key is string => key != null); + } + + /** TC7 — `SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?`, `sweepExpired`'s pass-2 row-less-object guard. */ + async existsByCacheKey(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['cache_key'] }); + return !!row; + } +} diff --git a/server/src/db/repositories/TrekPhotos.repository.ts b/server/src/db/repositories/TrekPhotos.repository.ts new file mode 100644 index 000000000..4d6b12e79 --- /dev/null +++ b/server/src/db/repositories/TrekPhotos.repository.ts @@ -0,0 +1,157 @@ +import type { TrekPhoto } from '../../types'; +import type { TrekPhotos } from '../entities/TrekPhotos.entity'; +import { coalesceParam } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `SELECT * FROM trek_photos WHERE id = ?`'s row (PH6/MA4 — the same + * statement text, one method serving both `TrekPhotoRegistrationService + * .resolve` and `MemoriesAccessService.canAccessTrekPhoto`'s entry read). + * Reuses the pre-existing `TrekPhoto` type from `../../types` (the row shape + * every consumer already imports) rather than a second, parallel row + * interface — its key set matches the entity's scalar columns exactly + * (`owner` is the hidden relation, `owner_id` its persist(false) twin). + */ +const _trekPhotoRowKeys: AssertRowKeys = true; + +export class TrekPhotosRepository extends TrekRepository { + /** PH1's SELECT half — `getOrCreate`'s existing-row lookup. */ + async findByProviderAsset(provider: string, asset_id: string, owner_id: number): Promise<{ id: number } | null> { + const row = await this.findOne({ provider, asset_id, owner: owner_id }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH2 — conditional passphrase re-encrypt on an already-registered remote photo. */ + async setPassphrase(id: number, passphrase: string): Promise { + await this.nativeUpdate({ id }, { passphrase }); + } + + /** PH3 — `INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type)`. */ + async insertRemote(data: { + provider: string; + asset_id: string; + owner_id: number; + passphrase: string | null; + media_type: string; + }): Promise { + return await this.insert({ + provider: data.provider, + asset_id: data.asset_id, + owner: data.owner_id, + passphrase: data.passphrase, + media_type: data.media_type, + }); + } + + /** PH4's SELECT half — `getOrCreateLocal`'s existing-row lookup. */ + async findLocalByPath(file_path: string): Promise<{ id: number } | null> { + const row = await this.findOne({ provider: 'local', file_path }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH5 — `INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms)`, always `provider = 'local'`. */ + async insertLocal(data: { + file_path: string; + thumbnail_path: string | null; + width: number | null; + height: number | null; + media_type: string; + duration_ms: number | null; + }): Promise { + return await this.insert({ + provider: 'local', + file_path: data.file_path, + thumbnail_path: data.thumbnail_path, + width: data.width, + height: data.height, + media_type: data.media_type, + duration_ms: data.duration_ms, + }); + } + + /** + * PH6 (`TrekPhotoRegistrationService.resolve`) / MA4 (`MemoriesAccessService + * .canAccessTrekPhoto`'s entry read) — byte-identical `SELECT * FROM + * trek_photos WHERE id = ?`, one method for both call sites (D4). + */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as TrekPhoto) : null; + } + + /** PH7 — retarget a local photo onto a provider once it has been uploaded there. */ + async retarget(id: number, provider: string, asset_id: string, owner_id: number): Promise { + await this.nativeUpdate({ id }, { provider, asset_id, owner: owner_id }); + } + + /** + * PH8 — stamp a generated local thumbnail. `COALESCE(width, ?)`/ + * `COALESCE(height, ?)` keep dimensions a re-generated thumbnail already + * knew (the caller-side guard for "don't blank what's known" stays on the + * service, same as legacy). + */ + async patchThumbnail(id: number, thumbnail_path: string, width: number, height: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + thumbnail_path, + width: coalesceParam(platform, 'width', width), + height: coalesceParam(platform, 'height', height), + }); + } + + /** + * PH9 — `COALESCE`d capture metadata write. The service computes the final + * `taken_at`/`lat`/`lng` (the all-empty early return, the pair-or-neither + * coordinate guard) before calling this. + * + * Legacy SQL (#1587): + * `UPDATE trek_photos SET taken_at = COALESCE(taken_at, ?), lat = COALESCE(lat, ?), lng = COALESCE(lng, ?) + * WHERE id = ? AND ((taken_at IS NULL AND ? IS NOT NULL) OR (lat IS NULL AND ? IS NOT NULL) OR (lng IS NULL AND ? IS NOT NULL))`. + * + * Answers whether the row learned anything. The guard is what makes that + * answer mean something: SQLite counts a matched row as changed even when + * every COALESCE kept the old value, and the caller broadcasts a journey + * refresh on it. The `? IS NOT NULL` halves are known here, so the guard is + * built as an `$or` over just the columns that bring a value; with none of + * them there is nothing to learn and no statement is issued. + */ + async patchCaptureMetadata(id: number, taken_at: string | null, lat: number | null, lng: number | null): Promise { + const learns: Array<{ taken_at: null } | { lat: null } | { lng: null }> = []; + if (taken_at != null) learns.push({ taken_at: null }); + if (lat != null) learns.push({ lat: null }); + if (lng != null) learns.push({ lng: null }); + if (learns.length === 0) return false; + const platform = this.getEntityManager().getPlatform(); + const changed = await this.nativeUpdate({ id, $or: learns }, { + taken_at: coalesceParam(platform, 'taken_at', taken_at), + lat: coalesceParam(platform, 'lat', lat), + lng: coalesceParam(platform, 'lng', lng), + }); + return changed > 0; + } + + /** PH11 — `DELETE FROM trek_photos WHERE id = ? AND provider != 'local'`; local photos are never auto-reclaimed here. */ + async deleteNonLocal(id: number): Promise { + return await this.nativeDelete({ id, provider: { $ne: 'local' } }); + } + + /** + * TH1 — `SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'`. + * The caller (`ThumbnailService.sweepOrphanThumbs`) always passes the + * literal `'journey/'` prefix; `$like` binds the wildcard-appended value as + * a genuine parameter (no dialect helper needed — a plain `LIKE` with a + * hardcoded prefix, no injection surface). Rows with a NULL `file_path` + * never match `LIKE`, so every returned value is a real string. + */ + async listByFilePathPrefix(prefix: string): Promise { + const rows = await this.find({ file_path: { $like: `${prefix}%` } }, { fields: ['file_path'] }); + return rows.map((row) => row.file_path).filter((path): path is string => path != null); + } + + /** TH2 — `SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'`, same shape as TH1 on the sibling column. */ + async listByThumbnailPathPrefix(prefix: string): Promise { + const rows = await this.find({ thumbnail_path: { $like: `${prefix}%` } }, { fields: ['thumbnail_path'] }); + return rows.map((row) => row.thumbnail_path).filter((path): path is string => path != null); + } +} diff --git a/server/src/db/repositories/TripAlbumLinks.repository.ts b/server/src/db/repositories/TripAlbumLinks.repository.ts new file mode 100644 index 000000000..1bb9d71b2 --- /dev/null +++ b/server/src/db/repositories/TripAlbumLinks.repository.ts @@ -0,0 +1,165 @@ +import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** MA7/MA8's shared row shape — album_id always needed, passphrase only by the sync path (MA8). */ +export interface TripAlbumLinkSyncRow { + album_id: string; + passphrase: string | null; +} + +/** + * `trip_album_links`'s single-table write shape (Plan 3e Task 7, + * UM7/UM10). `trip_id`/`id` are typed `number | string` — the legacy + * statements bound `tripId`/`linkId` raw (T5's raw-bind seam), including the + * primary-key `id` column in UM10's `DELETE ... WHERE id = ?`. + */ +interface TripAlbumLinksWriteKyselyDB { + trip_album_links: { + id: number | string; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + passphrase: string | null; + }; +} + +/** + * UM7's insert-only shape — `id`/`sync_enabled`/`last_synced_at`/`created_at` + * are autoincrement/defaulted and omitted from `.values()` (the legacy + * statement's own column list omits them too), so a SEPARATE interface from + * {@link TripAlbumLinksWriteKyselyDB} (the `FileLinksWriteKyselyDB` / + * `TripPhotosInsertKyselyDB` precedent) — that one declares `id` as + * required, which `.insertInto()` would then also require in `.values()`. + */ +interface TripAlbumLinksInsertKyselyDB { + trip_album_links: { + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + passphrase: string | null; + }; +} + +/** UM3's joined projection (`UnifiedMemoriesService.listTripAlbumLinks`) — own Kysely shape, one purpose-shaped interface per statement. */ +interface TripAlbumLinksListKyselyDB { + trip_album_links: { + id: number; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + sync_enabled: number; + last_synced_at: string | null; + created_at: string | null; + }; + users: { id: number; username: string | null }; +} + +/** UM3's row shape — `tal.*, u.username`. */ +export interface TripAlbumLinkListRow { + id: number; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + sync_enabled: number; + last_synced_at: string | null; + created_at: string | null; + username: string | null; +} + +export class TripAlbumLinksRepository extends TrekRepository { + /** + * MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same + * user-scoped WHERE, different SELECT list in the legacy text + * (`album_id` alone vs. `album_id, passphrase`); one method serving both, + * per the inventory's own proposal, since MA7's caller simply ignores the + * extra `passphrase` column. `SELECT album_id, passphrase FROM + * trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?` — note + * this is USER-scoped, not just trip-scoped: a link belongs to the user + * who created it, not the whole trip. + */ + async findScoped(id: number, trip_id: number, user_id: number): Promise { + const row = await this.findOne({ id, trip: trip_id, user: user_id }, { fields: ['album_id', 'passphrase'] }); + return row ? { album_id: row.album_id, passphrase: row.passphrase ?? null } : null; + } + + /** + * MA9 — `UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP + * WHERE id = ?`. Deliberately unscoped by trip/user, matching the legacy + * statement: the caller (`syncImmichAlbum`/`syncSynologyAlbum`) has + * already resolved `linkId` through `findScoped` (MA7/MA8) earlier in the + * same request, so this write relies entirely on that prior resolution — + * not a defect to fix here (see the class docstring on `MemoriesAccessService`). + */ + async touchSyncTime(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) }); + } + + /** + * UM7 (`UnifiedMemoriesService.createTripAlbumLink`) — `INSERT OR IGNORE + * INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, + * passphrase) VALUES (?, ?, ?, ?, ?, ?)`. `(trip, user, provider, + * album_id)` is the entity's real `uniques` constraint, so `ON CONFLICT + * (trip_id, user_id, provider, album_id) DO NOTHING` is the exact SQLite + * equivalent of the legacy `OR IGNORE`. Returns whether a row was actually + * inserted (legacy `result.changes === 0` drove the "already linked" 409). + */ + async insertIgnore(row: { trip_id: number | string; user_id: number; provider: string; album_id: string; album_name: string; passphrase: string | null }): Promise { + const result = await this.kysely() + .insertInto('trip_album_links') + .values({ trip_id: row.trip_id, user_id: row.user_id, provider: row.provider, album_id: row.album_id, album_name: row.album_name, passphrase: row.passphrase }) + .onConflict((oc) => oc.columns(['trip_id', 'user_id', 'provider', 'album_id']).doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** UM10 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?`. */ + async deleteScoped(id: number | string, trip_id: number | string, user_id: number): Promise { + await this.kysely() + .deleteFrom('trip_album_links') + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .execute(); + } + + /** + * UM3 (`UnifiedMemoriesService.listTripAlbumLinks`) — `SELECT tal.id, + * tal.trip_id, tal.user_id, tal.provider, tal.album_id, tal.album_name, + * tal.sync_enabled, tal.last_synced_at, tal.created_at, u.username FROM + * trip_album_links tal JOIN users u WHERE tal.trip_id = ? AND tal.provider + * IN (dynamic) ORDER BY tal.created_at ASC`. + */ + async listForTrip(trip_id: number | string, enabled_providers: string[]): Promise { + if (enabled_providers.length === 0) return []; + const rows = await this.kysely() + .selectFrom('trip_album_links as tal') + .innerJoin('users as u', 'u.id', 'tal.user_id') + .select([ + 'tal.id as id', + 'tal.trip_id as trip_id', + 'tal.user_id as user_id', + 'tal.provider as provider', + 'tal.album_id as album_id', + 'tal.album_name as album_name', + 'tal.sync_enabled as sync_enabled', + 'tal.last_synced_at as last_synced_at', + 'tal.created_at as created_at', + 'u.username as username', + ]) + .where('tal.trip_id', '=', trip_id) + .where('tal.provider', 'in', enabled_providers) + .orderBy('tal.created_at', 'asc') + .execute(); + return rows; + } +} diff --git a/server/src/db/repositories/TripDocumentLinks.repository.ts b/server/src/db/repositories/TripDocumentLinks.repository.ts new file mode 100644 index 000000000..418f8ccbc --- /dev/null +++ b/server/src/db/repositories/TripDocumentLinks.repository.ts @@ -0,0 +1,257 @@ +import { coalesceParam, currentTimestamp, nowPlusSeconds } from '../dialect/sql-functions'; +import type { TripDocumentLinks } from '../entities/TripDocumentLinks.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `trip_document_links` row (every scalar column). */ +export interface TripDocumentLinkRow { + id: number; + trip_id: number; + connection_id: number; + provider_id: string; + remote_scope_key: string; + remote_root_id: string | null; + remote_root_path: string | null; + remote_label: string; + direction: string; + delete_policy: string; + conflict_policy: string; + sync_enabled: number; + webhook_token: string | null; + webhook_secret: string | null; + webhook_subscription_id: string | null; + remote_cursor: string | null; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + failure_count: number; + next_attempt_at: string | null; + created_by: number | null; + created_at: string | null; + updated_at: string | null; +} + +const _linkRowKeys: AssertRowKeys = true; + +/** DSC17's dynamic SET list (`updateLink`) — only the fields present get written; `updated_at` is always stamped by {@link TripDocumentLinksRepository.updateFields} itself. */ +export interface TripDocumentLinkPatch { + direction?: string; + delete_policy?: string; + conflict_policy?: string; + sync_enabled?: number; + remote_label?: string; + /** The un-orphan reset (`sets.push("last_sync_state = 'never'")`) — set by the service, never inferred here. */ + last_sync_state?: string; +} + +/** + * `trip_document_links` — one trip's binding of a scope (folder/tag/space) at + * a `document_connections` row, both directions. + */ +export class TripDocumentLinksRepository extends TrekRepository { + /** + * DS1 (`DocSyncService.dueLinks`) — `SELECT * FROM trip_document_links + * WHERE sync_enabled=1 AND last_sync_state!='orphaned' AND + * failure_count { + if (enabledProviderIds.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + const rows = await this.qb('l') + .select(['l.*']) + .where({ + sync_enabled: 1, + last_sync_state: { $ne: 'orphaned' }, + failure_count: { $lt: circuitOpenAfter }, + $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: currentTimestamp(platform) } }], + provider_id: { $in: [...enabledProviderIds] }, + }) + .orderBy([ + { [coalesceParam(platform, 'next_attempt_at', '1970-01-01')]: 'asc' }, + { [coalesceParam(platform, 'last_sync_at', '1970-01-01')]: 'asc' }, + { id: 'asc' }, + ]) + .limit(limit) + .execute('all', false); + return rows; + } + + /** DSC12 (`listLinks`) — `SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id`. */ + async listForTrip(tripId: number): Promise { + return await this.qb('l').select(['l.*']).where({ trip_id: tripId }).orderBy({ id: 'asc' }).execute('all', false); + } + + /** DSC13 (`getLink`) — `SELECT * FROM trip_document_links WHERE id = ?`. */ + async findById(id: number): Promise { + return await this.qb('l').select(['l.*']).where({ id }).execute('get', false); + } + + /** DSC14 (`getLinkByToken`) — `SELECT * FROM trip_document_links WHERE webhook_token = ?`. **PUBLIC/ANONYMOUS ENTRYPOINT** (the webhook controller's sole authentication check). */ + async findByToken(token: string): Promise { + return await this.qb('l').select(['l.*']).where({ webhook_token: token }).execute('get', false); + } + + /** DSC15 (`createLink`'s pre-check) — `SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?`. */ + async findByTripConnectionScope(tripId: number, connectionId: number, scopeKey: string): Promise { + return await this.qb('l') + .select(['l.*']) + .where({ trip_id: tripId, connection_id: connectionId, remote_scope_key: scopeKey }) + .execute('get', false); + } + + /** + * DSC16 (`createLink`) — `INSERT INTO trip_document_links (trip_id, + * connection_id, provider_id, remote_scope_key, remote_root_id, + * remote_root_path, remote_label, direction, delete_policy, + * conflict_policy, sync_enabled, webhook_token, webhook_secret, + * created_by, next_attempt_at) VALUES (?×14, CURRENT_TIMESTAMP)`. + * `webhook_secret` arrives already encrypted (R7) — this method never + * calls `encryptSecrets` itself. `next_attempt_at` is seeded to "now" so a + * fresh binding sorts first under DS1's due-links ordering. + */ + async insertLink(data: { + trip_id: number; + connection_id: number; + provider_id: string; + remote_scope_key: string; + remote_root_id: string | null; + remote_root_path: string | null; + remote_label: string; + direction: string; + delete_policy: string; + conflict_policy: string; + sync_enabled: number; + webhook_token: string; + webhook_secret: string; + created_by: number; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.insert({ + trip: data.trip_id, + connection: data.connection_id, + provider_id: data.provider_id, + remote_scope_key: data.remote_scope_key, + remote_root_id: data.remote_root_id, + remote_root_path: data.remote_root_path, + remote_label: data.remote_label, + direction: data.direction, + delete_policy: data.delete_policy, + conflict_policy: data.conflict_policy, + sync_enabled: data.sync_enabled, + webhook_token: data.webhook_token, + webhook_secret: data.webhook_secret, + createdByRef: data.created_by, + next_attempt_at: currentTimestamp(platform), + }); + } + + /** + * DSC17 (`updateLink`) — the dynamic SET list, as a typed partial (see + * {@link TripDocumentLinkPatch}'s own docstring). `updated_at` is always + * stamped; the caller (`DocSyncConfigService.updateLink`) only calls this + * once it has confirmed at least one field of `patch` is present, matching + * the legacy `sets.length === 0` early return. + */ + async updateFields(id: number, patch: TripDocumentLinkPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) }); + } + + /** DSC19 (`deleteLink`, inside `uow.transactional`) — `DELETE FROM trip_document_links WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSC21 (`markOrphanedLinks`) — `UPDATE trip_document_links SET + * last_sync_state='orphaned', sync_enabled=0, updated_at=CURRENT_TIMESTAMP + * WHERE sync_enabled=1 AND last_sync_state!='orphaned' AND connection_id + * IN ()`. The predicate itself is + * `DocumentConnectionsRepository.listOrphanedIds()` (DSC20/DSC21's shared + * repository predicate) — this method takes the already-resolved id list, + * matching {@link dueLinks}'s same "resolve the subquery's rows first, + * filter here" shape. An empty list short-circuits to zero rows changed, + * the same outcome `IN (SELECT ... WHERE )` would produce. + */ + async markOrphanedByConnectionIds(connectionIds: readonly number[]): Promise { + if (connectionIds.length === 0) return 0; + const platform = this.getEntityManager().getPlatform(); + return await this.nativeUpdate( + { sync_enabled: 1, last_sync_state: { $ne: 'orphaned' }, connection_id: { $in: [...connectionIds] } }, + { last_sync_state: 'orphaned', sync_enabled: 0, updated_at: currentTimestamp(platform) }, + ); + } + + /** + * DS25 (`DocSyncService.recordLinkSuccess`) — `UPDATE trip_document_links + * SET remote_cursor=?, last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?, + * last_sync_error=?, failure_count=0, next_attempt_at=NULL, + * updated_at=CURRENT_TIMESTAMP WHERE id=?`. + */ + async recordSuccess(id: number, data: { remote_cursor: string | null; state: string; error_code: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + remote_cursor: data.remote_cursor, + last_sync_at: currentTimestamp(platform), + last_sync_state: data.state, + last_sync_error: data.error_code, + failure_count: 0, + next_attempt_at: null, + updated_at: currentTimestamp(platform), + }, + ); + } + + /** + * DS26 (`DocSyncService.recordLinkFailure`) — `UPDATE trip_document_links + * SET last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?, last_sync_error=?, + * failure_count=?, next_attempt_at=datetime('now','+'||?||' seconds'), + * updated_at=CURRENT_TIMESTAMP WHERE id=?`. `nextAttemptSeconds` (the + * caller's own `backoffSeconds(...)` runtime value) is turned into the + * `datetime('now', ...)` fragment HERE, through Task 0's `nowPlusSeconds` + * helper — the service passes the plain second count, never a + * pre-rendered fragment, keeping every dialect-function call inside the + * repository layer. + */ + async recordFailure(id: number, data: { state: string; error_code: string; failure_count: number; next_attempt_seconds: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + last_sync_at: currentTimestamp(platform), + last_sync_state: data.state, + last_sync_error: data.error_code, + failure_count: data.failure_count, + next_attempt_at: nowPlusSeconds(platform, data.next_attempt_seconds), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** DSCTRL3 (R2 — moved off the controller) — `UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?`, the webhook-registration follow-up. */ + async setWebhookSubscriptionId(id: number, subscriptionId: string): Promise { + await this.nativeUpdate({ id }, { webhook_subscription_id: subscriptionId }); + } +} diff --git a/server/src/db/repositories/TripFiles.repository.ts b/server/src/db/repositories/TripFiles.repository.ts new file mode 100644 index 000000000..11c8850b1 --- /dev/null +++ b/server/src/db/repositories/TripFiles.repository.ts @@ -0,0 +1,272 @@ +import { currentTimestamp } from '../dialect/sql-functions'; +import type { TripFiles } from '../entities/TripFiles.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A bare `trip_files` row — every scalar column of the entity, incl. the SIX + * `persist(false)` relation mirrors (`trip_id`, `place_id`, `reservation_id`, + * `note_id`, `uploaded_by`, `message_id` — the trap the class docstring + * below names). Kysely's `selectAll()` reads real DB columns directly, so it + * carries every one of these without the bare-QB-`.select([...])` drop a + * `columnRef` would otherwise be needed to avoid. + */ +export interface TripFileRow { + id: number; + trip_id: number; + place_id: number | null; + reservation_id: number | null; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; + description: string | null; + created_at: string | null; + note_id: number | null; + uploaded_by: number | null; + starred: number | null; + deleted_at: string | null; + message_id: number | null; +} + +const _tripFileRowKeys: AssertRowKeys = true; + +/** FILE_SELECT's joined projection (FL7/FL11/FL15/FL17/FL20). */ +export interface TripFileJoinRow extends TripFileRow { + reservation_title: string | null; + uploaded_by_name: string | null; + uploaded_by_avatar: string | null; +} + +interface TripFilesKyselyDB { + trip_files: TripFileRow; + reservations: { id: number; title: string }; + users: { id: number; username: string; avatar: string | null }; +} + +/** + * `trip_files` — trip attachments (uploads, expense receipts). Every read + * below goes through Kysely rather than the QueryBuilder: this entity has + * SIX `persist(false)` mirror columns (see {@link TripFileRow}'s docstring), + * and a bare QB `.select([...])` silently drops any of them it names without + * `columnRef` (the program-wide trap `RoadtripDayBoundariesRepository` + * documents) — Kysely's `selectAll()`/typed column list reads the real DB + * columns directly and has no such gap, so it is the simpler, uniformly-safe + * choice for a row this wide rather than six `columnRef` calls repeated + * across five read methods. + */ +export class TripFilesRepository extends TrekRepository { + /** FILE_SELECT (`:22-27`) — `trip_files f LEFT JOIN reservations r ON f.reservation_id = r.id LEFT JOIN users u ON f.uploaded_by = u.id`. */ + private joinedQuery() { + return this.kysely() + .selectFrom('trip_files as f') + .leftJoin('reservations as r', 'r.id', 'f.reservation_id') + .leftJoin('users as u', 'u.id', 'f.uploaded_by') + .selectAll('f') + .select(['r.title as reservation_title', 'u.username as uploaded_by_name', 'u.avatar as uploaded_by_avatar']); + } + + /** + * FL5 (`FilesService.getFileById`) — `SELECT * FROM trip_files WHERE id = ? + * AND trip_id = ?`, the trip-scoping guard reused by budget/collab reads + * (BG29/44, CB21) and by `permanentDeleteFile`/`softDeleteFile`/`remove`'s + * pre-image reads. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .executeTakeFirst(); + } + + /** FL6 (`FilesService.getDeletedFile`) — same as {@link findInTrip} plus `AND deleted_at IS NOT NULL`, the trash-scoped variant. */ + async findDeletedInTrip(id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is not', null) + .executeTakeFirst(); + } + + /** + * FL7 (`FilesService.listFiles`) — `` FILE_SELECT WHERE f.trip_id = ? AND + * f.message_id IS NULL AND f.deleted_at IS (NOT) NULL ORDER BY f.starred + * DESC, f.created_at DESC ``. `showTrash` toggles the `deleted_at` arm + * (D4: two WHERE shapes, one method, matching the legacy ternary). + */ + async listForTrip(trip_id: number, showTrash: boolean): Promise { + let q = this.joinedQuery().where('f.trip_id', '=', trip_id).where('f.message_id', 'is', null); + q = showTrash ? q.where('f.deleted_at', 'is not', null) : q.where('f.deleted_at', 'is', null); + return await q.orderBy('f.starred', 'desc').orderBy('f.created_at', 'desc').execute(); + } + + /** + * FL11/FL15/FL17/FL20 — `` FILE_SELECT WHERE f.id = ? `` , the SAME + * re-select text run after `createFile`/`updateFile`/`toggleStarred`/ + * `restoreFile`. No `trip_id` filter (matching the legacy statement — the + * caller already knows `id` is in-scope by construction, having just + * written it). + */ + async findByIdWithJoins(id: number): Promise { + return await this.joinedQuery().where('f.id', '=', id).executeTakeFirst(); + } + + /** FL22 (`FilesService.emptyTrash`'s read half) — `SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL`. */ + async listTrashed(trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is not', null) + .execute(); + } + + /** + * FL9 (`FilesService.createFile`) — `INSERT INTO trip_files (trip_id, + * place_id, reservation_id, filename, original_name, file_size, mime_type, + * description, uploaded_by) VALUES (?×9)`. Relation properties + * (`trip`/`place`/`reservation`/`uploadedByRef`), not their `persist(false)` + * `_id` mirrors — MikroORM accepts a raw scalar id for a `Ref`-typed + * property (`InviteTokensRepository.insertInvite`'s precedent). + * `uploadedByRef` is the entity's own name for the `uploaded_by` + * `joinColumn` relation (`TripFiles.entity.ts`). `trip_id: number | + * string` — the caller's own trip id, already vouched for by the + * guard/access check upstream, written (never compared) here; SQLite's + * own INTEGER-affinity conversion on INSERT normalises either shape the + * same way `DayAccommodationsRepository.insertStay`'s `trip_id` does, so + * no `toRowId` gate is needed on a write-only value (rule 21 governs the + * read-then-write seam, not every scalar a write ever carries). + */ + async insertFile(row: { + trip_id: number | string; + place_id: number | null; + reservation_id: number | null; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + description: string | null; + uploaded_by: number; + }): Promise { + return await this.insert({ + trip: row.trip_id, + place: row.place_id, + reservation: row.reservation_id, + filename: row.filename, + original_name: row.original_name, + file_size: row.file_size, + mime_type: row.mime_type, + description: row.description, + uploadedByRef: row.uploaded_by, + }); + } + + /** + * FL12 (`FilesService.updateFile`) — `UPDATE trip_files SET description = + * ?, place_id = ?, reservation_id = ? WHERE id = ?`. The caller resolves + * the `!== undefined ? (x || null) : current.x` presence sentinel BEFORE + * calling this (matching the legacy inline ternaries) — this method just + * writes the three final values verbatim. + */ + async updateFile(id: number, write: { description: string | null; place_id: number | null; reservation_id: number | null }): Promise { + await this.nativeUpdate({ id }, { description: write.description, place: write.place_id, reservation: write.reservation_id }); + } + + /** FL16 (`FilesService.toggleStarred`) — `UPDATE trip_files SET starred = ? WHERE id = ?`. */ + async setStarred(id: number, starred: number): Promise { + await this.nativeUpdate({ id }, { starred }); + } + + /** FL18 (`FilesService.softDeleteFile`) — `UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async softDelete(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { deleted_at: currentTimestamp(platform) }); + } + + /** FL19 (`FilesService.restoreFile`) — `UPDATE trip_files SET deleted_at = NULL WHERE id = ?`. */ + async restore(id: number): Promise { + await this.nativeUpdate({ id }, { deleted_at: null }); + } + + /** FL21 (`FilesService.permanentDeleteFile`) — `DELETE FROM trip_files WHERE id = ?`, run after a successful (or idempotent-missing) storage delete. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 5 (doc-sync) — cross-domain additive methods, flagged per the + // task brief (DS4/DS15/DS16/DS19/DS29/DS32/DS36 are cross-domain). DS15 + // reuses the existing {@link setStarred} above; these four are new. + // --------------------------------------------------------------------------- + + /** + * DS4/DS32 (`DocSyncService.applyAction` 'rename_local', `resolveConflict` + * keep='provider' rename) — `UPDATE trip_files SET original_name = ? + * WHERE id = ?`, the identical statement shape at both legacy call sites + * — one method, not two. + */ + async renameOriginalName(id: number, original_name: string): Promise { + await this.nativeUpdate({ id }, { original_name }); + } + + /** + * DS29 (`DocSyncService.resolveConflict`'s local-name read) — `SELECT + * original_name FROM trip_files WHERE id = ?`. Kysely, per this class's + * own docstring (a bare QB/`fields:` read risks silently dropping one of + * this entity's six `persist(false)` mirror columns — irrelevant to this + * one-column projection, but every other read in this file stays on + * Kysely for the same reason, so this one does too). + */ + async findOriginalName(id: number): Promise { + const row = await this.kysely().selectFrom('trip_files').select('original_name').where('id', '=', id).executeTakeFirst(); + return row?.original_name; + } + + /** + * DS19 (`DocSyncService.loadLocalDocuments`) — `SELECT f.id, + * f.original_name, f.file_size, f.mime_type, f.deleted_at FROM trip_files + * f WHERE f.trip_id = ? AND f.message_id IS NULL AND f.note_id IS NULL`. + * Excludes chat and note attachments — a binding mirrors the trip's + * PAPERWORK, not its conversation. + */ + async listSyncableForTrip(trip_id: number): Promise> { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'original_name', 'file_size', 'mime_type', 'deleted_at']) + .where('trip_id', '=', trip_id) + .where('message_id', 'is', null) + .where('note_id', 'is', null) + .execute(); + } + + /** + * DS36 (`DocSyncService.status`'s holdings count) — `SELECT COUNT(*) AS n + * FROM trip_files WHERE trip_id = ? AND deleted_at IS NULL AND message_id + * IS NULL AND note_id IS NULL`. + */ + async countActiveDocuments(trip_id: number): Promise { + const row = await this.kysely() + .selectFrom('trip_files') + .select((eb) => eb.fn.countAll().as('n')) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .where('message_id', 'is', null) + .where('note_id', 'is', null) + .executeTakeFirstOrThrow(); + return row.n; + } + + /** + * FL23 (`FilesService.emptyTrash`'s bulk delete) — `DELETE FROM trip_files + * WHERE id IN (dynamic)`, only for the ids whose storage delete already + * succeeded. `$in` for the dynamic list (rule 23) — no interpolated SQL. + */ + async deleteMany(ids: number[]): Promise { + if (ids.length === 0) return; + await this.nativeDelete({ id: { $in: ids } }); + } +} diff --git a/server/src/db/repositories/TripInviteTokens.repository.ts b/server/src/db/repositories/TripInviteTokens.repository.ts new file mode 100644 index 000000000..450721b63 --- /dev/null +++ b/server/src/db/repositories/TripInviteTokens.repository.ts @@ -0,0 +1,117 @@ +import type { TripInviteTokens } from '../entities/TripInviteTokens.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `trip-invite.service.ts::get`'s projection. */ +export interface TripInviteInfoRow { + token: string; + expires_at: string | null; + created_at: string; +} + +/** `trip-invite.service.ts::resolve`'s joined projection. */ +export interface TripInviteResolvedRow { + trip_id: number; + title: string; + expires_at: string | null; +} + +interface TripInviteResolveKyselyDB { + trip_invite_tokens: { token: string; trip_id: number; expires_at: string | null }; + trips: { id: number; title: string }; +} + +/** + * `trip_invite_tokens` (Plan 4 Task 1). One row per trip (`uniques: [{ + * properties: ['trip'] }]`, `TripInviteTokens.entity.ts`) — every method + * here is scoped by `trip_id`, raw-bind (`number | string`, D4's T5 escape + * hatch): `TripInviteService`'s own methods take `tripId: string | number` + * and bind it unconverted, matching the legacy statements exactly. + */ +export class TripInviteTokensRepository extends TrekRepository { + /** `get()` — `SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?`. */ + async findInfoByTrip(trip_id: number | string): Promise { + return await this.qb('t') + .select(['t.token', 't.expires_at', 't.created_at']) + .where('t.trip_id = ?', [trip_id]) + .execute('get', false); + } + + /** + * `createOrRotate`'s probe, inside `uow.transactional` — `SELECT id FROM + * trip_invite_tokens WHERE trip_id = ?`, a bare existence check deciding + * the UPDATE-vs-INSERT branch below. + */ + async existsForTrip(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where('t.trip_id = ?', [trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `createOrRotate`'s UPDATE branch (rotate) — `UPDATE trip_invite_tokens + * SET token = ?, expires_at = ?, created_by = ?, created_at = + * CURRENT_TIMESTAMP WHERE trip_id = ?`. `created_by` writes through + * `createdByRef`, the real joined-column relation property — `created_by` + * itself is a `persist(false)` mirror (`TripInviteTokens.entity.ts`). + * `CURRENT_TIMESTAMP` through the shared dialect helper (rule 5), matching + * the column's own `defaultRaw` literal exactly. + */ + async updateForTrip(trip_id: number | string, input: { token: string; expires_at: string | null; created_by: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb('t') + .update({ token: input.token, expires_at: input.expires_at, createdByRef: input.created_by, created_at: currentTimestamp(platform) }) + .where('t.trip_id = ?', [trip_id]) + .execute('run'); + } + + /** + * `createOrRotate`'s INSERT branch (first link) — `INSERT INTO + * trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, + * ?, ?, ?)`. `created_at` is not in the legacy column set — left to the + * column's own `defaultRaw` (`CURRENT_TIMESTAMP`), same as + * `DayNotesRepository.createNote`'s documented shape for an unnamed + * `defaultRaw` column. `trip`, not the raw `trip_id`: `insert()`'s typed + * relation property wants the entity's own primary-key type — the caller + * (`TripInviteService`) only ever calls this once a trip-scoped route has + * already resolved a REAL numeric trip id through `TripAccessGuard`, so + * `Number(...)` here mirrors the same seam `DayNotesService.create`'s own + * `Number(dayId)`/`Number(tripId)` conversion documents. + */ + async insertForTrip(input: { trip_id: number | string; token: string; created_by: number; expires_at: string | null }): Promise { + await this.insert({ + trip: Number(input.trip_id), + token: input.token, + createdByRef: input.created_by, + expires_at: input.expires_at, + }); + } + + /** `remove()` — `DELETE FROM trip_invite_tokens WHERE trip_id = ?`. */ + async deleteByTrip(trip_id: number | string): Promise { + await this.qb('t') + .delete() + .where('t.trip_id = ?', [trip_id]) + .execute('run'); + } + + /** + * `resolve(token)` — `SELECT t.id AS trip_id, t.title AS title, + * ti.expires_at AS expires_at FROM trip_invite_tokens ti JOIN trips t ON + * ti.trip_id = t.id WHERE ti.token = ?`. Kysely (a cross-table join to + * `trips`, the same escape-hatch order every other cross-table read in + * this program uses) — expiry itself is checked in JS by the caller + * (`TripInviteService.resolve`'s own docstring), never in this query. + */ + async resolveTokenToTrip(token: string): Promise { + const row = await this.kysely() + .selectFrom('trip_invite_tokens as ti') + .innerJoin('trips as t', 't.id', 'ti.trip_id') + .select(['t.id as trip_id', 't.title as title', 'ti.expires_at as expires_at']) + .where('ti.token', '=', token) + .executeTakeFirst(); + return row as TripInviteResolvedRow | undefined; + } +} diff --git a/server/src/db/repositories/TripMembers.repository.ts b/server/src/db/repositories/TripMembers.repository.ts new file mode 100644 index 000000000..6e94289b1 --- /dev/null +++ b/server/src/db/repositories/TripMembers.repository.ts @@ -0,0 +1,315 @@ +import type { TripMembers } from '../entities/TripMembers.entity'; +import { Trips } from '../entities/Trips.entity'; +import { caseWhenEquals, coalesce } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * One row of `TripMembersService.listMembers` (`trip-members.service.ts:117-125`, + * TM2). `is_guest` stays the raw SQLite integer here — the `!!m.is_guest` + * boolean coercion is a service-layer default (D4), same split every other + * repository in this program keeps. + */ +export interface MemberWithUserAndInviterRow { + id: number; + username: string; + email: string; + avatar: string | null; + is_guest: number; + role: string; + added_at: string | null; + invited_by_username: string | null; +} + +export class TripMembersRepository extends TrekRepository { + /** + * The user ids a trip may refer to: its members plus the owner. Byte-for-byte + * the legacy `DatabaseService.rosterUserIds`'s statement in intent — + * `SELECT user_id FROM trip_members WHERE trip_id = ? UNION SELECT user_id + * FROM trips WHERE id = ?` — as two reads merged through a `Set` (which + * already dedupes, the same guarantee `UNION` gives) rather than one `UNION` + * statement: the two `SELECT`s read different tables with no FK between + * them, so a single-entity `QueryBuilder` (rooted on `TripMembers`) cannot + * express the second half without losing rows on a trip that has no + * members yet — an inner join from `trip_members` would produce zero rows + * for exactly the case (an owner-only trip) the legacy statement's second + * `SELECT` exists to cover. `trips` has no `TripMembersRepository` field to + * reach it through, so this repository asks the `EntityManager` for + * `TripsRepository` the same way `DatabaseService`/the guards resolve a + * repository outside their own entity — one `EntityManager`, two + * repositories, two independent reads. + */ + async rosterUserIds(trip_id: number | string): Promise> { + // Raw conditions (D4's T5 escape hatch), not `.where({ 'm.trip': trip_id })`: + // MikroORM's typed filter rejects a `string` against the relation's + // branded id type, and coercing to `Number(trip_id)` first would change + // the raw-bind id-shape parity `TripsRepository`'s docstring describes. + // `m.trip_id`/`t.id` are the physical column names. + const members = await this.qb('m') + .select(['m.user']) + .where('m.trip_id = ?', [trip_id]) + .execute<{ user_id: number }[]>('all', false); + + const trips = this.getEntityManager().getRepository(Trips); + const owner = await trips + .qb('t') + .select(['t.user']) + .where('t.id = ?', [trip_id]) + .execute<{ user_id: number } | undefined>('get', false); + + const ids = new Set(members.map((m) => m.user_id)); + if (owner) ids.add(owner.user_id); + return ids; + } + + /** + * `SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC` + * (`trip-membership.service.ts:29-31`, TB2) — the owner is excluded by + * construction (the table has no row for the owner), matching the legacy + * statement exactly. Raw-bind (D4's T5 escape hatch), the same + * `number | string` seam `TripsRepository.findAccessible` documents. + */ + async listUserIdsByTrip(trip_id: number | string): Promise { + const rows = await this.qb('m') + .select(['m.user']) + .where('m.trip_id = ?', [trip_id]) + .orderBy({ 'm.added_at': 'asc' }) + .execute<{ user_id: number }[]>('all', false); + return rows.map((r) => r.user_id); + } + + /** + * `SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?` + * (`trip-membership.service.ts:70`, TB5 — `joinTripAsMember`'s + * already-a-member check, one leg of a non-transactional check-then-act + * sequence, §18.4: unchanged by this conversion; also `trip-members + * .service.ts:151`, TM5 — `addMember`'s own "already has access" check, + * the SAME non-transactional shape against TM6's insert). `TripMembershipService + * .joinTripAsMember`'s `tripId` is always a real `number`; `TripMembersService + * .addMember`'s is still the route's unconverted `tripId` + * (`string | number`) — raw-bind (D4's T5 escape hatch, the same seam + * `TripsRepository.findAccessible` documents) covers both without forking + * the statement into two methods. + */ + async exists(trip_id: number | string, user_id: number): Promise { + const row = await this.qb('m') + .select(['m.id']) + .where('m.trip_id = ?', [trip_id]) + .andWhere({ user: user_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)` + * (`trip-membership.service.ts:72`, TB6 — the third leg of the same + * non-transactional sequence as `exists` above; also `trip-members + * .service.ts:155`, TM6 — `addMember`'s own insert, and `:227`, TM17 — + * `createGuest`'s membership row for the new guest, inside its own + * transaction). `invitedBy` may be `null`. `trip_id: number`, not + * `number | string`: unlike `exists`'s WHERE-comparison shape, `insert`'s + * typed `EntityData` requires the relation's real `Primary` type. + * This is still exact parity for `TripMembersService`'s two still-string + * `tripId` callers (TM6/TM17): by the time either call happens, the + * route's `tripId` has already matched a real trip through one of the + * four raw-bind access primitives earlier in the SAME request (the + * controller's inline `canAccessTrip` check for `addMember`; + * `TripOwnerGuard`'s `isOwner`/`findAccessible` for `createGuest`'s + * owner-only route) — which only succeeds for a string SQLite's own + * affinity rules already recognise as numeric, and `Number(...)` of + * exactly that class of string always agrees with SQLite's own + * numeric-affinity conversion. The two call sites document this and pass + * `Number(tripId)`, not a widened raw bind. + */ + async addMember(trip_id: number, user_id: number, invitedBy: number | null): Promise { + await this.insert({ trip: trip_id, user: user_id, invited_by: invitedBy }); + } + + /** + * `SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, + * u.avatar, u.is_guest, CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END + * as role, m.added_at, COALESCE(ib.display_name, ib.username) as + * invited_by_username FROM trip_members m JOIN users u ON u.id = m.user_id + * LEFT JOIN users ib ON ib.id = m.invited_by WHERE m.trip_id = ? + * ORDER BY m.added_at ASC` (`trip-members.service.ts:117-125`, TM2) — + * the self-join on `users` (once as `u` for the member, once as `ib` for + * the inviter) via `TripMembers.entity.ts`'s `invitedByRef` relation, + * which exists precisely for this join. Every joined-alias column is + * selected with an explicit inline `as ` (the `PlaceRatingsRepository + * .listForPlaces`/`AssignmentParticipantsRepository.insertIgnore` + * precedent — rule 19's trap: selecting a joined alias's bare `.id` + * without an explicit alias risks MikroORM's own `__` prefix + * for a relation-property select, verified NOT to reproduce the legacy's + * bare `id`/`email`/`avatar`/`is_guest`/`added_at` keys otherwise), never + * left to MikroORM's own column-collision aliasing. + */ + async listWithUserAndInviter(trip_id: number | string, owner_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('m') + .join('m.user', 'u') + .leftJoin('m.invitedByRef', 'ib') + .select([ + 'u.id as id', + coalesce(platform, 'u.display_name', 'u.username').as('username'), + 'u.email as email', + 'u.avatar as avatar', + 'u.is_guest as is_guest', + caseWhenEquals(platform, 'u.id', owner_id, 'owner', 'member').as('role'), + 'm.added_at as added_at', + coalesce(platform, 'ib.display_name', 'ib.username').as('invited_by_username'), + ]) + .where('m.trip_id = ?', [trip_id]) + .orderBy({ 'm.added_at': 'asc' }) + .execute('all', false); + } + + /** + * `SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id + * WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1` + * (`trip-members.service.ts:235-237`, TM18) — security-sensitive: the + * scoping guard that keeps guest mutations trip-local (#1362). Rooted on + * `TripMembers` (this repository's entity) rather than `Users`: the + * legacy statement's `users u JOIN trip_members m` becomes `trip_members m + * JOIN users u` here, the same join and the same result set, only rooted + * the other way because that is this repository's entity. + * + * Raw-bind on `user_id` (not `.andWhere({ user: user_id })`) plus a + * `Number.isFinite` short-circuit guard — the same reason `remove()` below + * has both: `renameGuest`/`deleteGuest`'s callers still do a bare + * `Number.parseInt(userId)` on the route's `:userId`, unvalidated — a + * non-numeric id parses to `NaN`. **Mechanism (Task 6 review's I1, + * captured by wrapping `better-sqlite3.prepare`, not assumed): MikroORM + * formats every query with ALL parameters inlined as escaped literals — + * the params array that reaches better-sqlite3 is always empty — so a + * non-finite `NaN`/`Infinity` renders as a bare, unquoted SQL token and + * SQLite fails at PREPARE time (`no such column: NaN`, a 500), before any + * statement runs. A raw `?` placeholder does not avoid this (a typed + * filter has the identical failure) because the inlining happens during + * formatting, not at the bind step.** The legacy statement bound it as a + * genuine better-sqlite3 parameter, matching zero rows (404 `'Guest not + * found'`, not a crash) — only skipping the query for a non-finite + * `user_id` reproduces that byte for byte. + */ + async isGuestOfTrip(trip_id: number | string, user_id: number): Promise { + if (!Number.isFinite(user_id)) return false; + const row = await this.qb('m') + .join('m.user', 'u') + .select(['u.id as id']) + .where('m.trip_id = ?', [trip_id]) + .andWhere('m.user_id = ?', [user_id]) + .andWhere({ 'u.is_guest': 1 }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) + * VALUES (?, ?, ?)` (`trip-members.service.ts:200`, TM15) — + * security-sensitive: `transferOwnership`'s third statement, giving the + * former owner membership back. `upsert` with `onConflictAction: 'ignore'` + * on the table's real unique key, named by entity property (`trip`/`user`, + * `AssignmentParticipantsRepository.insertIgnore`'s precedent for a + * relation-backed `onConflictFields` list) — `UNIQUE(trip_id, user_id)` + * per `TripMembersSchema`'s own `uniques` declaration, not assumed. + * `trip_id: number`, the same reasoning as `addMember`'s docstring above + * (an `upsert` is INSERT-shaped; the caller passes `Number(tripId)`, + * justified there — `transferOwnership`'s route is `TripOwnerGuard`-gated, + * so `tripId` has already matched a real trip through the raw-bind + * `isOwner`/`findAccessible` primitive earlier in the same request). + */ + async addIgnoringConflict(trip_id: number, user_id: number, invitedBy: number | null): Promise { + // `invitedByRef`, not `invited_by`: the latter is a `persist(false)` mirror + // column (like `trip_id`/`user_id`) that `upsert` — unlike `insert` above, + // per `addMember`'s test — silently drops when building its INSERT. + // `invitedByRef` is the real, joined-column-backed relation property. + await this.upsert( + { trip: trip_id, user: user_id, invitedByRef: invitedBy }, + { onConflictFields: ['trip', 'user'], onConflictAction: 'ignore' }, + ); + } + + /** + * `DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?` + * (`trip-members.service.ts:166`, TM8 `removeMember`; `:198`, TM14 + * `transferOwnership`'s "no longer a plain member" step — + * security-sensitive). `trip_id: number` (Plan 4 Task 8a, narrowed from + * `number | string` — its last raw-string caller, + * `TripMembersController.removeMember`, now parses its route param once + * itself before calling this), so `trip_id` is a typed `.where({ trip: + * trip_id })` filter below; `user_id` stays a raw `m.user_id = ?` + * placeholder — NOT the same seam, a different reason entirely: + * `TripMembersController.removeMember` still does a bare + * `Number.parseInt(userId)` on the route's `:userId`, unvalidated (rule + * 15's exact trap — a non-numeric id parses to `NaN`), and a typed filter + * cannot carry a `NaN` through this ORM layer intact (see below). + * + * **`Number.isFinite` guard, checked directly against a compiled boot, not + * assumed:** a typed `.andWhere({ user: user_id })` filter renders `NaN` as + * a literal, unquoted SQL token (`WHERE … AND user_id = NaN`), which + * SQLite's parser reads as a column reference and rejects at PREPARE time + * (`no such column: NaN`, a 500). Switching to a raw `m.user_id = ?` + * placeholder does NOT fix this (Task 6 review's I1, captured by wrapping + * `better-sqlite3.prepare`): MikroORM formats every query with ALL + * parameters inlined as escaped literals — the params array that reaches + * better-sqlite3 is always empty — so `NaN` renders as that same bare + * token regardless of whether the predicate was written as a typed filter + * or a raw `?` placeholder (reproduced directly: still `no such column: + * NaN` at prepare time, before any bind happens — the inlining happens + * during formatting, not at the bind step). The legacy statement bound + * `targetUserId` (`NaN`) straight into a real better-sqlite3 parameter, + * which silently matches zero rows and returns 200 `{ success: true }` — + * only skipping the query entirely for a non-finite `user_id` reproduces + * that, byte for byte, since neither the typed filter nor the raw + * placeholder route can pass `NaN` through the ORM layer intact. + */ + async remove(trip_id: number, user_id: number): Promise { + if (!Number.isFinite(user_id)) return; + await this.qb('m') + .delete() + .where({ trip: trip_id }) + .andWhere('m.user_id = ?', [user_id]) + .execute('run'); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository + // is rooted at `trip_members` joined to `users`, and RP3's shape is + // deliberately NOT `listWithUserAndInviter`'s (TM2's COALESCE'd + // `username`), so it gets its own method here rather than a variant + // parameter on TM2's. + // --------------------------------------------------------------------------- + + /** + * RP3 (`trips.rpc.ts::members`) — `SELECT u.id, u.username, u.display_name, + * u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE + * tm.trip_id = ?`. Emits raw `username` AND `display_name` as SEPARATE + * fields — deliberately NOT `listWithUserAndInviter`'s (TM2) COALESCE'd + * single `username` (the ruling: "do not harmonise" — a plugin reading + * `trips.members` and the REST/MCP roster are two different wire shapes on + * purpose, inventory §18.10). + */ + async listRawUsernameAndDisplayName(trip_id: number | string): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null }[]> { + return await this.qb('m') + .join('m.user', 'u') + .select(['u.id', 'u.username', 'u.display_name', 'u.avatar']) + .where('m.trip_id = ?', [trip_id]) + .execute<{ id: number; username: string; display_name: string | null; avatar: string | null }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (UC4, `user-cleanup.service.ts::cleanupUserReferences`) — + // additive: the table's own Plan 3c ownership, mis-filed by an earlier + // ledger under Plan 3g/3j and left unconverted. + // --------------------------------------------------------------------------- + + /** + * UC4 — `UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?` + * (GDPR erasure: severs every membership row's reference to a user being + * deleted, without deleting the membership itself). Filtered and set + * through `invitedByRef`, the real joined-column relation property — + * `invited_by` itself is the `persist(false)` mirror `addIgnoringConflict`'s + * docstring above already names. + */ + async clearInvitedBy(userId: number): Promise { + await this.nativeUpdate({ invitedByRef: userId }, { invitedByRef: null }); + } +} diff --git a/server/src/db/repositories/TripPhotos.repository.ts b/server/src/db/repositories/TripPhotos.repository.ts new file mode 100644 index 000000000..1392275b7 --- /dev/null +++ b/server/src/db/repositories/TripPhotos.repository.ts @@ -0,0 +1,212 @@ +import type { TripPhotos } from '../entities/TripPhotos.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `trip_photos`'s single-table write/lookup shape (Plan 3e Task 7, + * UM4/UM5/UM6/UM8/UM9). `trip_id`/`album_link_id` are typed `number | + * string` — the legacy statements bound `tripId`/`albumLinkId` raw, straight + * from the route param, with no `Number()`/`toRowId` conversion (the same + * T5 raw-bind seam `TripsRepository.findForViewer`'s docstring documents), + * so this Kysely shape has to accept both to bind the identical value. + */ +interface TripPhotosKyselyDB { + trip_photos: { + id: number; + trip_id: number | string; + user_id: number; + photo_id: number; + shared: number; + album_link_id: number | string | null; + added_at: string | null; + }; +} + +/** + * UM4's insert-only shape — `id`/`added_at` are autoincrement/`DEFAULT + * CURRENT_TIMESTAMP` and omitted from `.values()` (the legacy statement's + * own column list omits them too), so Kysely's `InsertObject` needs a + * SEPARATE interface that doesn't declare them (the `FileLinksWriteKyselyDB` + * precedent) — {@link TripPhotosKyselyDB} declares every column, so passing + * it to `.insertInto()` would require `id`/`added_at` in every `.values()`. + */ +interface TripPhotosInsertKyselyDB { + trip_photos: { + trip_id: number | string; + user_id: number; + photo_id: number; + shared: number; + album_link_id: number | string | null; + }; +} + +/** + * UM2's joined projection (`UnifiedMemoriesService.listTripPhotos`) — three + * tables, so its own Kysely shape rather than folding onto + * {@link TripPhotosKyselyDB} (one purpose-shaped interface per statement, + * the `FileLinksRepository` precedent). + */ +interface TripPhotosListKyselyDB { + trip_photos: { trip_id: number | string; user_id: number; photo_id: number; shared: number; added_at: string | null }; + trek_photos: { id: number; asset_id: string | null; provider: string }; + users: { id: number; username: string | null; avatar: string | null }; +} + +/** UM2's row shape — `tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar`. */ +export interface TripPhotoListRow { + photo_id: number; + asset_id: string | null; + provider: string; + user_id: number; + shared: number; + added_at: string | null; + username: string | null; + avatar: string | null; +} + +export class TripPhotosRepository extends TrekRepository { + /** + * PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ? + * LIMIT 1`, used by `TrekPhotoRegistrationService.deleteIfOrphan` alongside + * the `journey_photos` half of the same orphan check, converted onto + * {@link JourneyPhotosRepository.existsForPhoto} (Plan 3g Task 4). + */ + async existsForPhoto(photo_id: number): Promise { + const row = await this.findOne({ photo: photo_id }, { fields: ['id'] }); + return !!row; + } + + /** + * MA3 — the regular-trip branch of `MemoriesAccessService.canAccessUserPhoto`: + * is this provider asset shared by this exact user into this exact trip? + * `SELECT 1 FROM trip_photos tp JOIN trek_photos tkp ON tkp.id = tp.photo_id + * WHERE tp.user_id = ? AND tkp.asset_id = ? AND tkp.provider = ? AND + * tp.trip_id = ? AND tp.shared = 1 LIMIT 1`. + */ + async existsSharedForUser(trip_id: number, user_id: number, asset_id: string, provider: string): Promise { + const row = await this.qb('tp') + .select(['tp.id']) + .join('tp.photo', 'tkp') + .where({ 'tp.user': user_id, 'tp.trip': trip_id, 'tp.shared': 1, 'tkp.asset_id': asset_id, 'tkp.provider': provider }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * MA5's source data — every trip a photo is shared into (`shared = 1`), + * for `MemoriesAccessService.canAccessTrekPhoto` to check each one against + * `TripsRepository.findAccessible` (R4: reuse the existing accessibility + * builder rather than hand-translating the legacy `EXISTS(...UNION + * ALL...)` predicate into a second, independent form). Legacy statement: + * `SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1 + * AND EXISTS(...trip_members/trips...) LIMIT 1` — the `EXISTS` half is + * exactly `findAccessible`'s own predicate, so this method hands back the + * candidate trip ids and the caller does the short-circuiting `OR` over + * `findAccessible`'s results instead. + * + * Full-row `find()`, not a `fields: ['trip_id']` projection: `trip_id` is a + * `persist(false)` scalar twin of the `trip` relation, and a narrowed + * `fields` selection silently drops it from the hydrated result (the same + * bare-`persist(false)`-select trap `RoadtripDayBoundariesRepository` + * documents — verified empirically here too, not assumed) — only an + * unrestricted `find()` (or `columnRef` through a `qb()`) hydrates it. + */ + async listTripIdsSharedForPhoto(photo_id: number): Promise { + const rows = await this.find({ photo: photo_id, shared: 1 }); + return rows.map((row) => row.trip_id); + } + + /** + * UM4 (`UnifiedMemoriesService._addTripPhoto`) — `INSERT OR IGNORE INTO + * trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES + * (?, ?, ?, ?, ?)`. `(trip, user, photo)` is the entity's real `uniques` + * constraint (this file's `TripPhotosSchema`), so `ON CONFLICT + * (trip_id, user_id, photo_id) DO NOTHING` is the exact SQLite equivalent + * of the legacy `OR IGNORE`. Returns whether a row was actually inserted + * (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows` + * is Kysely's dialect-general affected-row count, 0 when the conflict + * target already existed and the insert was ignored. + */ + async insertIgnore(row: { trip_id: number | string; user_id: number; photo_id: number; shared: number; album_link_id: number | string | null }): Promise { + const result = await this.kysely() + .insertInto('trip_photos') + .values({ trip_id: row.trip_id, user_id: row.user_id, photo_id: row.photo_id, shared: row.shared, album_link_id: row.album_link_id }) + .onConflict((oc) => oc.columns(['trip_id', 'user_id', 'photo_id']).doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** UM5 (`UnifiedMemoriesService.setTripPhotoSharing`) — `UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */ + async setShared(trip_id: number | string, user_id: number, photo_id: number, shared: number): Promise { + await this.kysely() + .updateTable('trip_photos') + .set({ shared }) + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .where('photo_id', '=', photo_id) + .execute(); + } + + /** UM6 (`UnifiedMemoriesService.removeTripPhoto`) — `DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */ + async deleteForUserPhoto(trip_id: number | string, user_id: number, photo_id: number): Promise { + await this.kysely() + .deleteFrom('trip_photos') + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .where('photo_id', '=', photo_id) + .execute(); + } + + /** UM8 (`UnifiedMemoriesService.removeAlbumLink`'s pre-tx orphan-candidate list) — `SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */ + async listPhotoIdsForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('trip_photos') + .select(['photo_id']) + .where('trip_id', '=', trip_id) + .where('album_link_id', '=', album_link_id) + .execute(); + return rows.map((r) => r.photo_id); + } + + /** UM9 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */ + async deleteForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise { + await this.kysely() + .deleteFrom('trip_photos') + .where('trip_id', '=', trip_id) + .where('album_link_id', '=', album_link_id) + .execute(); + } + + /** + * UM2 (`UnifiedMemoriesService.listTripPhotos`) — `SELECT tp.photo_id, + * tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, + * u.username, u.avatar FROM trip_photos tp JOIN trek_photos tkp JOIN + * users u WHERE tp.trip_id = ? AND (tp.user_id = ? OR tp.shared = 1) AND + * tkp.provider IN (dynamic) ORDER BY tp.added_at ASC`. The caller already + * guards the empty-`enabled_providers` case (`ServiceResult` 400 before + * this is ever reached); the early return here is defensive, matching an + * `IN ()` that can never match any row. + */ + async listForTrip(trip_id: number | string, user_id: number, enabled_providers: string[]): Promise { + if (enabled_providers.length === 0) return []; + const rows = await this.kysely() + .selectFrom('trip_photos as tp') + .innerJoin('trek_photos as tkp', 'tkp.id', 'tp.photo_id') + .innerJoin('users as u', 'u.id', 'tp.user_id') + .select([ + 'tp.photo_id as photo_id', + 'tkp.asset_id as asset_id', + 'tkp.provider as provider', + 'tp.user_id as user_id', + 'tp.shared as shared', + 'tp.added_at as added_at', + 'u.username as username', + 'u.avatar as avatar', + ]) + .where('tp.trip_id', '=', trip_id) + .where((eb) => eb.or([eb('tp.user_id', '=', user_id), eb('tp.shared', '=', 1)])) + .where('tkp.provider', 'in', enabled_providers) + .orderBy('tp.added_at', 'asc') + .execute(); + return rows; + } +} diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts new file mode 100644 index 000000000..a40ea9aa2 --- /dev/null +++ b/server/src/db/repositories/Trips.repository.ts @@ -0,0 +1,1115 @@ +import { QueryFlag } from '@mikro-orm/core'; +import type { Trips } from '../entities/Trips.entity'; +import { coalesceParam, currentTimestamp, nowDateOffset } from '../dialect/sql-functions'; +import type { AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { tripAccessExpr } from './_shared/trip-access'; + +/** What a trip-scoped request learns about the trip once access is verified. */ +export interface TripAccess { + id: number; + user_id: number; + currency: string | null; +} + +/** + * `SELECT * FROM trips WHERE id = ?`, every scalar column — the row + * `TripReadModelService.getTripSummary` (TR-B) and, once Task 7 lands, + * `TripsService.getRaw` (TP22) both need, `feed_token` included: the + * `withoutFeedToken()` JS strip that guards the credential stays in the + * SERVICE (TR-B's ruling), so this repository method must hand the column + * back intact for that strip to have something to delete. + */ +export interface TripRawRow { + id: number; + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + is_archived: number | null; + reminder_days: number | null; + feed_token: string | null; + created_at: string | null; + updated_at: string | null; +} + +const _tripRawRowKeys: AssertRowKeys = true; + +/** + * `TRIP_SELECT`'s output row (inventory §11a) — every `TripRawRow` column + * plus the four computed/joined columns the projection adds. `feed_token` + * is always `null` here (SQL-side blanking via `NULL AS feed_token`/its + * Kysely equivalent, never a JS strip) — see `TripsRepository + * .tripSelectQuery`'s docstring. + */ +export interface TripSelectRow extends Omit { + feed_token: null; + day_count: number; + place_count: number; + is_owner: number; + owner_username: string; + shared_count: number; +} + +/** The narrow `trips`/`users`/`days`/`places`/`trip_members` shape `tripSelectQuery` needs (`this.kysely()`'s typed `DB` argument). */ +interface TripSelectKyselyDB { + trips: { + // `number | string`, not a bare `number`: the raw-bind seam (D4's T5 + // escape hatch) — `findForViewer`'s `WHERE t.id = ?` accepts the route's + // unconverted id with no coercion, the same seam every QB-based method + // in this file documents. Widening the TYPE (not spelling `sql\`...\``, + // which `no-restricted-syntax` bans in `src/db/repositories/**`) is what + // lets `.where('t.id', '=', trip_id)` accept a `string` here. + id: number | string; + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + is_archived: number | null; + reminder_days: number | null; + feed_token: string | null; + created_at: string | null; + updated_at: string | null; + }; + users: { id: number; username: string }; + trip_members: { id: number; trip_id: number; user_id: number }; + days: { id: number; trip_id: number }; + places: { id: number; trip_id: number }; +} + +// Task 7 review L4, absorbed here (Task 8 touches the same file): the 13 +// hand-listed columns of `TripSelectKyselyDB['trips']` above are a second, +// independently-typed copy of `Trips`'s own scalar columns (Kysely's typed +// `DB` argument can't be derived from the entity metadata the way `qb()`'s +// generics are) — this fails `tsc` the moment a column is added to or +// removed from `Trips` without updating this interface to match, instead of +// silently drifting (the same drift guard `_tripRawRowKeys`/`_dayRowKeys`/… +// already give every hand-written row interface in this program). +const _tripSelectKyselyDbKeys: AssertRowKeys = true; + +/** TP21's output row (inventory §11c) — `relevance` is the computed sort key, never sent to the client (the controller destructures `{id, title, start_date, end_date}` only). */ +export interface ActiveTripRow { + id: number; + title: string; + start_date: string | null; + end_date: string | null; + relevance: number; +} + +/** The narrow `trips`/`trip_members` shape `activeTrip` needs. */ +interface ActiveTripKyselyDB { + trips: { id: number; title: string; start_date: string | null; end_date: string | null; user_id: number; is_archived: number | null }; + trip_members: { id: number; trip_id: number; user_id: number }; +} + +export class TripsRepository extends TrekRepository { + /** + * The `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)` half `findAccessible` + * and `listAccessibleIds` (Plan 3c Task 1, TB3) share — one join builder, + * per the task brief's ruling that the two must derive from the same + * source rather than hand-keeping two copies in sync (the cross-plan note + * in the inventory: TB3 and `TripsService.list`'s id half are "the same + * query with different projections"). No explicit return-type annotation, + * for the same inference reason `TrekRepository.kysely()` gives (the + * QueryBuilder's fluent generic parameters do not survive being spelled + * out via a separate type alias) — every caller chains `.select()`/ + * `.where()`/`.orderBy()` straight off this method's return value. + */ + private accessibleTripsQuery(user_id: number) { + return this.qb('t') + .leftJoin('t.trip_members_collection', 'm', { 'm.user': user_id }) + .andWhere({ $or: [{ 't.user': user_id }, { 'm.user': { $ne: null } }] }); + } + + /** + * The trip if the user owns it or is a member of it, else undefined. + * + * Byte-for-byte the legacy `canAccessTrip` statement: + * SELECT t.id, t.user_id, t.currency 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) + * + * `trip_id: number | string` (Plan 3c Task 0b): the legacy free function + * bound whatever `req.params.tripId` resolved to straight into the + * statement with no `Number()`/`toRowId` conversion first — the raw-bind + * seam. `TripAccessGuard` still does `Number(tripId)` before calling this + * (unchanged), but `DatabaseService.canAccessTrip`/`isOwner` and every + * other of the 41 in-cluster callers pass a `number | string` through + * unconverted, so this method (and `isOwner` below) must accept and bind + * that raw value rather than coerce it — a coercion here would silently + * change the `'0x10'`/`'007'`-shaped id parity the boot matrix pins + * (`Number('0x10') === 16`, a real trip id, where SQLite's own text/integer + * affinity comparison of the unconverted string never matches one). + */ + async findAccessible(trip_id: number | string, user_id: number): Promise { + const row = await this.accessibleTripsQuery(user_id) + .select(['t.id', 't.user', 't.currency']) + // Raw condition (D4's T5 escape hatch), not `.where({ 't.id': trip_id })`: + // MikroORM's typed filter rejects a `string` against `t.id`'s branded + // `number` type, and coercing to `Number(trip_id)` first would be + // exactly the parity break the class docstring above describes. + // `t.id`/`t.user_id` are the physical column names (not translated + // through the entity's `trip`/`user` property aliasing the way a typed + // filter object is), so the raw text names them directly. + .andWhere('t.id = ?', [trip_id]) + // `mapResults: false` leaves the driver's row alone: the keys are the + // column names, which is why the result type is spelled out here rather + // than inferred from the entity's properties. + .execute<{ id: number; user_id: number; currency: string | null } | undefined>('get', false); + if (!row) return undefined; + return { id: row.id, user_id: row.user_id, currency: row.currency ?? null }; + } + + /** + * DY35 (`days.service.ts::insert`'s dated path) — `UPDATE trips SET + * end_date = ? WHERE id = ?`: a dated insert extends the trip by one day. + * Plan 3c Task 7 (`TripsService`) reuses this for its own `end_date` + * writes rather than duplicating the statement. + */ + async setEndDate(id: number, end_date: string | null): Promise { + await this.nativeUpdate({ id }, { end_date }); + } + + /** + * DY38/DY42 (`DaysService.appendDated`, `DayRemovalService.renumber`) — + * `UPDATE trips SET end_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id + * = ?`: a day added after the last date, or a deleted day that took the last + * date along, moves the trip's end and stamps it. + */ + async setEndDateTouched(id: number, end_date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { end_date, updated_at: currentTimestamp(platform) }); + } + + /** + * TP79 (`trips.service.ts::searchPlaces`, #2190) — `SELECT p.trip_id, + * p.name FROM places p JOIN trips t ON t.id = p.trip_id LEFT JOIN + * trip_members m ON m.trip_id = t.id AND m.user_id = :userId WHERE + * (t.user_id = :userId OR m.user_id IS NOT NULL) AND (p.name LIKE :like + * ESCAPE '\' OR p.address LIKE :like ESCAPE '\') ORDER BY p.trip_id, p.name + * LIMIT 500`. `likePattern` arrives pre-escaped (`%…%`, the service's job), + * as in PlacesRepository.listForTrip. + */ + async searchPlaceNames(user_id: number, likePattern: string): Promise<{ trip_id: number; name: string }[]> { + return await this.accessibleTripsQuery(user_id) + .join('t.places_collection', 'p') + .select(['p.trip', 'p.name']) + .andWhere("(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\')", [likePattern, likePattern]) + .orderBy([{ 'p.trip': 'asc' }, { 'p.name': 'asc' }]) + .limit(500) + // The limit is on the joined place rows, like the legacy statement's. + // Without the flag MikroORM moves a limit over a to-many join into a + // subquery on trips and drops the LIKE filter from the outer select. + .setFlag(QueryFlag.DISABLE_PAGINATE) + .execute<{ trip_id: number; name: string }[]>('all', false); + } + + /** `SELECT id FROM trips WHERE id = ? AND user_id = ?` */ + async isOwner(trip_id: number | string, user_id: number): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where('t.id = ?', [trip_id]) + .andWhere({ user: user_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `SELECT user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:23`, + * TB1) — `row ? row.user_id : null` stays the caller's decision to make + * ("owner id for budget/MCP guards"); this returns exactly that shape. + * Raw-bind (D4's T5 escape hatch), same `number | string` seam as + * `findAccessible`/`isOwner` above. + */ + async getOwnerId(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.user']) + .where('t.id = ?', [trip_id]) + .execute<{ user_id: number } | undefined>('get', false); + return row ? row.user_id : null; + } + + /** + * `SELECT id, user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:66`, + * TB4) — `joinTripAsMember`'s first check-then-act read (§18.4: the + * sequence stays non-transactional, unchanged by this conversion). The + * only caller (`TripMembershipService.joinTripAsMember(tripId: number, …)`) + * always passes a real `number`, so this takes one too — no raw-bind seam + * to preserve here, unlike `findAccessible`/`isOwner`/`getOwnerId`. + */ + async findIdAndOwner(trip_id: number): Promise<{ id: number; user_id: number } | undefined> { + return this.qb('t') + .select(['t.id', 't.user']) + .where({ id: trip_id }) + .execute<{ id: number; user_id: number } | undefined>('get', false); + } + + /** + * MR8 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'trip'` arm) — + * `SELECT id FROM trips WHERE id = ?`, a bare existence probe (the entity + * type IS the trip, so the id resolves to itself once the row is + * confirmed to exist). + */ + async existsById(id: number): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where({ id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * HR9 (Plan 3j Task 5, `host/rpc/host-surface.rpc.ts#sharesATrip`, private) + * — byte-for-byte the legacy self-joined statement: + * SELECT 1 FROM trips t + * LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? + * LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? + * WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) + * AND (t.user_id = ? OR m2.user_id IS NOT NULL) + * LIMIT 1 + * — "does ANY trip exist where both users are owner-or-member" (the + * `users.getById` plugin RPC's own access gate — a bespoke bilateral + * membership check, NOT `canAccessTrip`-shaped, which is why it lives here + * rather than reusing `accessibleTripsQuery` — that builder joins ONE + * user's membership; this needs two independent joins, one per user, so + * neither side's `$or` can be satisfied by the other's row. + */ + async sharesTripWith(userIdA: number, userIdB: number): Promise { + const row = await this.qb('t') + .leftJoin('t.trip_members_collection', 'm1', { 'm1.user': userIdA }) + .leftJoin('t.trip_members_collection', 'm2', { 'm2.user': userIdB }) + .select(['t.id']) + .andWhere({ + $and: [ + { $or: [{ 't.user': userIdA }, { 'm1.user': { $ne: null } }] }, + { $or: [{ 't.user': userIdB }, { 'm2.user': { $ne: null } }] }, + ], + }) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id + * AND m.user_id = :userId WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) + * ORDER BY t.created_at DESC` (`trip-membership.service.ts:40-46`, TB3) — + * the id half of `TripsService.list(userId, null)`, sharing `findAccessible`'s + * join builder per the task brief's ruling (this class's `accessibleTripsQuery` + * above). + */ + async listAccessibleIds(user_id: number): Promise { + const rows = await this.accessibleTripsQuery(user_id) + .select(['t.id']) + .orderBy({ 't.created_at': 'desc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** + * `SELECT title FROM trips WHERE id = ?` (`trip-members.service.ts:156`, + * TM7) — `addMember`'s notification-body read; the caller applies its own + * `?? 'Untitled'` fallback (D4). Raw-bind (D4's T5 escape hatch), the same + * `number | string` seam `findAccessible` documents. + */ + async getTitle(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.title']) + .where('t.id = ?', [trip_id]) + .execute<{ title: string } | undefined>('get', false); + return row?.title ?? null; + } + + /** + * `SELECT id, title, user_id FROM trips WHERE id = ?` + * (`trip-members.service.ts:180`, TM9) — `transferOwnership`'s first + * check-then-act read (miss → 404 `'Trip not found'`; wrong owner → 400 + * `'Only the owner can transfer ownership'`, both decided by the caller). + * Raw-bind, the same seam as `getTitle` above. + */ + async findIdTitleOwner(trip_id: number | string): Promise<{ id: number; title: string; user_id: number } | undefined> { + return this.qb('t') + .select(['t.id', 't.title', 't.user']) + .where('t.id = ?', [trip_id]) + .execute<{ id: number; title: string; user_id: number } | undefined>('get', false); + } + + /** + * `UPDATE trips SET user_id = ? WHERE id = ?` (`trip-members.service.ts:196`, + * TM13) — security-sensitive: the ownership handover itself, the first of + * `transferOwnership`'s three transactional statements. `trip_id: number` + * (Plan 4 Task 8a, narrowed from `number | string`): its one production + * caller already passes `trip.id`, the real row `findIdTitleOwner` + * resolved earlier in the same transaction, never the route's raw string + * (see `transferOwnership`'s own comment on why). `nativeUpdate`, typed + * filter throughout — nothing here needs the raw-bind escape hatch any + * more. + */ + async setOwner(trip_id: number, user_id: number): Promise { + await this.nativeUpdate({ id: trip_id }, { user: user_id }); + } + + /** + * `SELECT * FROM trips WHERE id = ?` — `TripReadModelService.getTripSummary`'s + * trip read (`trip-read-model.service.ts:52`, TR-B) and, once Task 7 lands, + * `TripsService.getRaw` (TP22, `trips.service.ts:389`) — one method, two + * sites, per the inventory's own note that they are the identical + * statement. `feed_token` comes back intact; `withoutFeedToken()` stays a + * JS strip in each CALLER (TR-B's ruling — this repository never blanks + * it itself, unlike `TRIP_SELECT`'s `NULL AS feed_token` SQL-side trick). + * `.select(['t.*'])` — MikroORM's QueryBuilder recognises the bare + * `.*` marker (verified against `QueryBuilder.js`'s own + * `prepareFields`, not assumed) and emits every column unprefixed, the + * same `SELECT t.*` shape the legacy statement used. Raw-bind, the same + * `number | string` seam `getTitle`/`findIdTitleOwner` above preserve. + */ + async findRaw(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.*']) + .where('t.id = ?', [trip_id]) + .execute('get', false); + return row ?? null; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`TripsService`) — `TRIP_SELECT` (inventory §11a) becomes + // ONE builder here (`tripSelectQuery`), expressed through `this.kysely()` + // (a typed one-table-plus-joins DB interface, the same escape hatch + // `DayAssignmentsRepository.effectiveStart`/`DayAssignmentsRepository + // .listForTimeSort` use): three correlated scalar `COUNT(*)` subqueries a + // QueryBuilder select list cannot express, a `CASE WHEN` projected column, + // and `NULL AS feed_token` placed AFTER `t.*` (via `.selectAll('t')` then + // a later `.select()` of the literal) so the duplicate key wins in the row + // object exactly the way the legacy statement's column order does — not by + // hand-listing every `trips` column (§18.5: that loses the "blanked once, + // survives a schema change" guarantee the legacy comment describes). + // Verified directly with `.compile()` against a real in-memory DB before + // wiring in (task report, TRIP_SELECT SQL captures) — a fully seeded trip + // (owner, member, stranger; day/place counts; `feed_token` set in the DB) + // produces `feed_token: null`, `is_owner`, `owner_username`, all four + // counts, byte-identical in shape to a raw run of the legacy statement. + // --------------------------------------------------------------------------- + + /** + * `this.kysely()`'s typed `DB` argument for every `TRIP_SELECT`-shaped + * query below — narrowed to the columns those queries read or join + * through, per `WebauthnChallengesRepository.claimChallenge`'s docstring + * (entity-metadata inference is not what a hand-written statement wants). + */ + private tripSelectQuery(user_id: number) { + return this.kysely() + .selectFrom('trips as t') + .innerJoin('users as u', 'u.id', 't.user_id') + .selectAll('t') + .select((eb) => [ + eb.val(null).as('feed_token'), + eb.selectFrom('days as d').select((eb2) => eb2.fn.countAll().as('c')).whereRef('d.trip_id', '=', 't.id').as('day_count'), + eb.selectFrom('places as p').select((eb2) => eb2.fn.countAll().as('c')).whereRef('p.trip_id', '=', 't.id').as('place_count'), + eb.case().when('t.user_id', '=', user_id).then(1).else(0).end().as('is_owner'), + 'u.username as owner_username', + eb.selectFrom('trip_members as tm').select((eb2) => eb2.fn.countAll().as('c')).whereRef('tm.trip_id', '=', 't.id').as('shared_count'), + ]); + } + + /** + * DY37 (`DaysService.getTripForViewer`) — `TRIP_SELECT + * WHERE t.id = :tripId`, with no access join: a day write that changed the + * trip (its end date, its day count) re-reads it in list shape for its own + * caller, who already holds access, and the rows of every viewer are the + * same apart from `is_owner`. + */ + async findListShapeById(trip_id: number | string, user_id: number): Promise { + const row = await this.tripSelectQuery(user_id).where('t.id', '=', trip_id).executeTakeFirst(); + return row as TripSelectRow | undefined; + } + + /** + * TP20 (`trips.service.ts::get`) — `TRIP_SELECT` scoped to one trip AND + * the access predicate (`t.user_id = :userId OR m.user_id IS NOT NULL`), + * i.e. "the trip if this viewer may see it, else nothing". Also serves + * TP19 (`create`'s post-insert re-select) and TP29 (`updateTrip`'s + * post-write re-select): both call sites are only ever reached after the + * acting user has already passed an access check for this exact trip + * (creator = owner; `updateTrip`'s callers all gate on `canAccessTrip`/ + * `requireTripEdit` first), so the access predicate here is always + * trivially satisfied in those two contexts and the result is identical + * to the legacy's unscoped `WHERE t.id = :tripId` re-select. Also serves + * `TripMembersService.getTripForViewer` (TM1, Task 6 review's "for Task + * 7" note) — same reasoning: every caller already holds access. + * + * Raw-bind (`number | string`, D4's T5 escape hatch — `TripSelectKyselyDB`'s + * `trips.id` is typed `number | string` for exactly this): the legacy + * statement bound the route's unconverted `tripId` with no `Number()`/ + * `toRowId` conversion, matching `findAccessible`'s documented seam. + */ + async findForViewer(trip_id: number | string, user_id: number): Promise { + const row = await this.tripSelectQuery(user_id) + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .where('t.id', '=', trip_id) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)) + .executeTakeFirst(); + return row as TripSelectRow | undefined; + } + + /** + * TP16/TP17 (`trips.service.ts::list`) — `TRIP_SELECT` + the access join, + * optionally filtered by `is_archived`, `ORDER BY t.created_at DESC`. + * `archived === null` reproduces TP16 (no filter); any other value (0 or + * 1, the controller's own coercion) reproduces TP17. + */ + async listForUser(user_id: number, archived: number | null): Promise { + let query = this.tripSelectQuery(user_id) + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)); + if (archived !== null) query = query.where('t.is_archived', '=', archived); + const rows = await query.orderBy('t.created_at', 'desc').execute(); + return rows as TripSelectRow[]; + } + + /** + * TP21 (`trips.service.ts::activeTrip`) — the triple `CASE WHEN … + * relevance` projection and the double-`CASE WHEN` `ORDER BY` (one ASC, + * one DESC). The legacy statement's `ORDER BY` items reference the + * `relevance` SELECT-list alias (`CASE WHEN relevance < 2 …`, `CASE WHEN + * relevance = 2 …`); reproduced here by recomputing the equivalent + * boolean expression inline at each of the four sites (the `.select()` + * and three `.orderBy()` calls below) rather than referencing the alias, + * since Kysely's typed `ORDER BY` can't name a computed SELECT alias and + * `sql`-tagged templates are banned in `src/db/repositories/**` — and a + * shared private helper can't be typed across these four callbacks either + * (`leftJoin`'s alias widens each callback's own `ExpressionBuilder` + * table-set in a way a separate generic method signature can't match + * structurally). Semantically identical to the legacy statement — SQLite + * evaluates both forms to the same three-way ordering, verified directly + * against `.compile()`/real rows before wiring in (task report, + * TRIP_SELECT SQL captures): `running` = `relevance = 0`, `upcoming` = + * `relevance = 1`, `running OR upcoming` = `relevance < 2`. + */ + async activeTrip(user_id: number, today: string): Promise { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .select((eb) => [ + eb.case() + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)])) + .then(0) + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)])) + .then(1) + .else(2) + .end() + .as('relevance'), + ]) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)) + .where('t.is_archived', '=', 0) + .orderBy((eb) => + eb.case() + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)])) + .then(0) + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)])) + .then(1) + .else(2) + .end(), 'asc') + .orderBy((eb) => + eb.case() + .when(eb.or([ + eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]), + eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]), + ])) + .then(eb.ref('t.start_date')) + .end(), 'asc') + .orderBy((eb) => + eb.case() + .when(eb.not(eb.or([ + eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]), + eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]), + ]))) + .then(eb.ref('t.start_date')) + .end(), 'desc') + .limit(1) + .executeTakeFirst(); + return row as ActiveTripRow | undefined; + } + + /** + * TP18 (`trips.service.ts::create`'s INSERT) — the column set of the + * legacy statement, written verbatim (the `||`/clamp defaults are the + * service's own, decided before this call, per the program's D2 split). + * Returns `em.insert()`'s PK (R6's `lastInsertRowid` replacement). + */ + async insertTrip(input: { + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string; + reminder_days: number; + }): Promise { + return await this.insert({ + user: input.user_id, + title: input.title, + description: input.description, + start_date: input.start_date, + end_date: input.end_date, + currency: input.currency, + reminder_days: input.reminder_days, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP37 (`trips.service.ts::copy`'s INSERT) — `INSERT INTO trips (user_id, + * title, description, start_date, end_date, currency, cover_image, + * is_archived, reminder_days) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`. A + * DIFFERENT column set from `insertTrip` (TP18, `create`'s own INSERT): + * this one also writes `cover_image` (verbatim from the source row) and + * `is_archived` as a hard-coded literal `0` — a copy is never archived, + * regardless of the source's own flag, so `is_archived` takes no + * parameter at all here, matching the legacy statement's own `0` literal + * rather than a bound value. The `title || src.title` fallback and the + * `reminder_days ?? 3` default are the SERVICE's own decisions (already + * resolved before this call), same split as `insertTrip`. + */ + async insertTripCopy(input: { + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + reminder_days: number; + }): Promise { + return await this.insert({ + user: input.user_id, + title: input.title, + description: input.description, + start_date: input.start_date, + end_date: input.end_date, + currency: input.currency, + cover_image: input.cover_image, + is_archived: 0, + reminder_days: input.reminder_days, + }); + } + + /** + * TP25 (`trips.service.ts::updateTrip`) — `UPDATE trips SET title=?, + * description=?, start_date=?, end_date=?, currency=?, is_archived=?, + * cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP WHERE + * id=?`. The `||`/`!== undefined` pre-image fallbacks are the SERVICE's + * own (R5/§18.6 — this write stays OUTSIDE the days-regeneration + * transaction, unchanged); this takes the final, already-decided values + * and writes them verbatim, `updated_at` stamped unconditionally like the + * legacy statement. Named `updateTripRow` (not `updateTrip`, the + * deliverable list's own name) to keep it unambiguous next to + * `TripsService.updateTrip`, which calls it. + * + * `is_archived: number | null` (Task 7 security review L1, absorbed here + * — not `number`): the caller's own pre-image fallback + * (`data.is_archived !== undefined ? … : trip.is_archived`) can legitimately + * be `null` (a stored `NULL` the request never touched), and the legacy + * statement bound that value through unfolded — a `number`-only signature + * here would tempt a caller to paper over that with its own `?? 0`, the + * exact regression the review found. + */ + async updateTripRow(id: number, data: { + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string; + is_archived: number | null; + cover_image: string | null; + reminder_days: number; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...data, updated_at: currentTimestamp(platform) }); + } + + /** TP35 (`trips.service.ts::updateCoverImage`) — `UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setCoverImage(id: number, cover_image: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { cover_image, updated_at: currentTimestamp(platform) }); + } + + /** TP34 (`trips.service.ts::remove`) — `DELETE FROM trips WHERE id = ?` (security-sensitive: cascades the whole trip). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 5 (`FeedsService`) — additive: the anonymous ICS feed token + // lifecycle. FD1/FD2-4/FD9/FD11 (inventory §5). `trip_id`/`user_id` are + // always real `number`s here, unlike `findAccessible`/`getTitle`/etc above: + // the two feed-token REST routes resolve and validate the trip id through + // `TripAccessGuard` (`@Trip()` hands the controller the already-numeric, + // already-access-checked `TripAccess.id`) before `FeedsService` ever runs, + // and the MCP surface's own Zod schema types `tripId` as + // `z.number().int().positive()` — there is no raw route-string seam to + // preserve on this file's newest methods the way `findAccessible`'s + // docstring describes for the pre-existing ones. + // --------------------------------------------------------------------------- + + /** + * FD1 (`feeds.service.ts::tripTokenRow`) — `SELECT feed_token FROM trips + * WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members + * WHERE user_id = ?))`. R3 (one visibility predicate, one source): reuses + * `accessibleTripsQuery` above — the SAME join builder `findAccessible`/ + * `listAccessibleIds` already share — rather than re-deriving the + * "reachable" predicate a third way. `null` covers both "no such trip / + * not reachable" and "reachable but the column is NULL" on purpose: every + * caller (`FeedsService.getTripToken`/`generateTripToken`) treats the two + * identically (no usable token), the same collapse the legacy + * `row?.feed_token` optional-chain already made. + */ + async getFeedTokenIfReachable(trip_id: number, user_id: number): Promise { + const row = await this.accessibleTripsQuery(user_id) + .select(['t.feed_token']) + .andWhere({ id: trip_id }) + .execute<{ feed_token: string | null } | undefined>('get', false); + return row ? row.feed_token : null; + } + + /** + * FD2/FD3/FD4 (`generateTripToken`/`rotateTripToken`/`disableTripToken`) — + * ONE method for the three legacy statements (identical text, `token | + * null` is the only thing that varies): `UPDATE trips SET feed_token = ? + * WHERE `. Returns the affected row count — §18.7's 0-row case + * (`generateTripToken` still hands back a URL for a token this write never + * stored, past the guard; R4 mirrors that exactly, unfixed) is now visible + * to a caller instead of a fire-and-forget `.run()`. + * + * `nativeUpdate`, not `accessibleTripsQuery(...).update(...)`: SQLite has + * no `UPDATE ... JOIN` — chaining `.update()` onto a query builder that + * already carries `accessibleTripsQuery`'s `.leftJoin()` throws + * (`near "left": syntax error`, proven directly against a real DB before + * choosing this shape, not assumed). The typed `FilterQuery` below reaches + * for the SAME relation the join builder does (`trip_members_collection`, + * `t.trip_members_collection` in `accessibleTripsQuery`) rather than a + * hand-rolled string predicate (rule 23): MikroORM detects the to-many + * relation in the `$or` and auto-rewrites the whole `nativeUpdate` into an + * `UPDATE trips SET ... WHERE id IN (SELECT id FROM (SELECT DISTINCT t.id + * FROM trips t LEFT JOIN trip_members t1 ON t.id = t1.trip_id WHERE t.id = + * ? AND (t.user_id = ? OR t1.user_id = ?)) AS t)` — the legacy statement's + * `IN`-subquery shape, generated from the identical join, not typed twice. + * Proven row-identical to `accessibleTripsQuery`/`findAccessible` by + * TRIPREPO-041's extended cross-method parity matrix (owner/member/ + * stranger/archived). + */ + async setFeedTokenIfReachable(trip_id: number, user_id: number, token: string | null): Promise { + return await this.nativeUpdate( + { id: trip_id, $or: [{ user: user_id }, { trip_members_collection: { user: user_id } }] }, + { feed_token: token }, + ); + } + + /** + * FD9 (`feeds.service.ts::buildTripIcs`) — `SELECT id FROM trips WHERE + * feed_token = ?`. The anonymous credential lookup: `token` arrives + * untrusted, straight from the URL path, with no access check (the token + * itself IS the access check — `idx_trips_feed_token`'s partial UNIQUE + * index, `WHERE feed_token IS NOT NULL`, is what makes it a safe credential + * to look up by at all: a row with a NULL token can never match, so a + * disabled feed's old URL 404s instead of resolving to whichever trip + * happens to have a NULL column). + */ + async findIdByFeedToken(token: string): Promise { + const row = await this.findOne({ feed_token: token }, { fields: ['id'] }); + return row?.id; + } + + /** + * FD11 (`feeds.service.ts::buildUserIcs`) — `SELECT id FROM trips WHERE + * (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = + * ?)) AND is_archived = 0 AND (end_date IS NULL OR end_date >= ?) ORDER BY + * start_date ASC`. The scope of the anonymous all-trips feed: reachable + * (`accessibleTripsQuery`, R3 again), unarchived, not yet ended (or + * undated). Named `listReachableActiveTrips` per the task brief's ruling. + */ + async listReachableActiveTrips(user_id: number, cutoff: string): Promise { + const rows = await this.accessibleTripsQuery(user_id) + .select(['t.id']) + .andWhere({ is_archived: 0 }) + .andWhere({ $or: [{ end_date: null }, { end_date: { $gte: cutoff } }] }) + .orderBy({ 't.start_date': 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 5 (`PublicApiService`) — additive: the trip reads on 3d + // (public-api joins Plan 3d for its reads on 3d tables, R3/§14.6). All + // three convert an existing `public-api.service.ts` statement in place; + // `reservations`/`day_accommodations` statements in that file stay raw + // (Tasks 2/3 own those tables and had not landed when this task ran — see + // the task report). + // --------------------------------------------------------------------------- + + /** + * `PublicApiService.listTrips`'s statement — `SELECT id, title, + * description, start_date, end_date, currency, is_archived, updated_at + * FROM trips WHERE id IN (...) ORDER BY start_date DESC, id DESC`. `ids` + * is already the caller's own access-checked list + * (`TripMembershipService.listAccessibleTripIds`, unchanged, not this + * plan's file) — this method does no access check of its own, matching + * the legacy statement, which trusted the same pre-filtered id list. + */ + async listSummariesByIds(ids: number[]): Promise { + if (ids.length === 0) return []; + return this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.currency', 't.is_archived', 't.updated_at']) + .where({ id: { $in: ids } }) + .orderBy({ start_date: 'desc', id: 'desc' }) + .execute('all', false); + } + + /** + * `PublicApiService.getTrip`'s row read — the SAME 8-column projection as + * `listSummariesByIds` above, for one trip. The caller's own + * `db.canAccessTrip` check (unchanged, a different file) runs first; this + * method, like the legacy statement it replaces, does no access check. + */ + async findSummaryById(id: number): Promise { + const row = await this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.currency', 't.is_archived', 't.updated_at']) + .where({ id }) + .execute('get', false); + return row ?? null; + } + + /** + * `share.service.ts:264` SH7 (`getSharedTripData`) — `SELECT id, title, + * description, start_date, end_date, cover_image, currency FROM trips + * WHERE id = ?`. A different 7-column public projection from + * `TripSummaryProjectionRow`'s 8 above (`cover_image` in, `is_archived`/ + * `updated_at` out — a public share-link viewer never sees either). No + * access check here, matching the legacy statement — the caller's own + * `verifyTripAccess`/token validity gates this read. + * + * `id: number` (Plan 4 Task 8a, rule 23): unlike `findAccessible`/ + * `isOwner`, this is never on the guard's raw-`req.params` path — its one + * caller (`share.service.ts#getSharedTripData`) passes `shareRow.trip_id`, + * a `number` straight off a Kysely-typed `share_tokens` row, never an + * unconverted URL param — so the `number | string` raw-bind seam those + * two methods' own docstrings describe does not apply here, and this one + * narrows to a typed filter now rather than waiting on that seam's guard- + * level parse (deferred, out of this task's scope). + */ + async findPublicForShare(id: number): Promise { + return await this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.cover_image', 't.currency']) + .where({ 't.id': id }) + .execute('get', false); + } + + /** + * `PublicApiService.buildTravellers`'s statement — the owner row (a + * literal `1 AS is_owner`) `UNION ALL` every member row (`0 AS is_owner`), + * `ORDER BY is_owner DESC`. A QueryBuilder `UNION ALL` of two different + * FROMs has no single alias to chain a `.select()`/`.where()` off (unlike + * `accessibleTripsQuery`'s single-FROM join), so this is Kysely + * (`this.kysely()`, the same escape hatch `tripSelectQuery`'s own + * docstring documents) — `eb.val(1)`/`eb.val(0)` for the + * two literal branches, the same construct `tripSelectQuery`'s `NULL AS + * feed_token` already uses in this file. + */ + async listTravellerUsernames(trip_id: number): Promise { + const owner = this.kysely() + .selectFrom('trips as t') + .innerJoin('users as u', 'u.id', 't.user_id') + .select((eb) => ['u.username as username', eb.val(1).as('is_owner')]) + .where('t.id', '=', trip_id); + const member = this.kysely() + .selectFrom('trip_members as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .select((eb) => ['u.username as username', eb.val(0).as('is_owner')]) + .where('m.trip_id', '=', trip_id); + const rows = await owner.unionAll(member).orderBy('is_owner', 'desc').execute(); + return rows as TravellerUsernameRow[]; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule. + // --------------------------------------------------------------------------- + + /** BG15/BG16/BGM2/BGM3 — `SELECT currency FROM trips WHERE id = ?`, four legacy call sites, one statement. */ + async getCurrency(id: number | string): Promise { + const row = await this.kysely<{ trips: { id: number; currency: string | null } }>() + .selectFrom('trips') + .select('currency') + .where('id', '=', id as number) + .executeTakeFirst(); + return row?.currency; + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 4 (`ReminderJobsService`) — additive. + // --------------------------------------------------------------------------- + + /** + * RJ2 (`reminder-jobs.service.ts`'s boot banner, inside `runOnBoot`) — + * `SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date + * IS NOT NULL`. + */ + async countActiveWithReminders(): Promise { + return this.count({ reminder_days: { $gt: 0 }, start_date: { $ne: null } }); + } + + /** + * RJ3 (`reminder-jobs.service.ts#tripTick`) — restructured per Task 0's R9 + * ruling: the legacy statement concatenated a per-row COLUMN + * (`t.reminder_days`) into a `date('now', '+' || t.reminder_days || ' + * days')` modifier, which no bound-parameter or JS-constant-spelled helper + * can express. This narrows the SQL read to the same WHERE minus the date + * comparison (`SELECT t.id, t.title, t.user_id, t.reminder_days, + * t.start_date FROM trips t WHERE t.reminder_days > 0 AND t.start_date IS + * NOT NULL`); the caller (`ReminderJobsService.tripTick`) does the + * per-row date-equality check in JS against `start_date`, per R9's exact + * verified shape. + */ + async listReminderCandidates(): Promise { + return await this.qb('t') + .select(['t.id', 't.title', 't.user', 't.reminder_days', 't.start_date']) + .andWhere({ reminder_days: { $gt: 0 }, start_date: { $ne: null } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 6 (`SystemNoticesService`) — additive. + // --------------------------------------------------------------------------- + + /** SN3 (`systemNotices/service.ts`'s former `getActiveNoticesFor`) — `SELECT COUNT(*) AS count FROM trips WHERE user_id = ?`, feeding the `noTrips` condition kind. */ + async countForUser(userId: number): Promise { + return this.count({ user: userId }); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive. + // --------------------------------------------------------------------------- + + /** + * AT1 (`AtlasService#getUserTrips`, the join every other atlas read + * relies on transitively) — `SELECT DISTINCT t.* 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 = ? ORDER BY t.start_date DESC`. Reuses + * `accessibleTripsQuery` (Plan 3c Task 1) rather than re-deriving the + * join: its `m.user: { $ne: null }` half is the LEFT-JOIN-then-`IS NOT + * NULL` spelling of the legacy `m.user_id = ?` — the join's own `ON … + * AND m.user_id = ?` already restricts every matched `m` row to this + * user, so the two WHERE spellings pick out the identical row set. No + * explicit `.distinct()`: `TripMembers`' own `uniques: [['trip',' + * user']]` constraint means at most one `m` row per `(trip, user)`, so + * the join can never fan a `t` row out to more than one result row. + */ + async listOwnedOrMember(user_id: number): Promise { + return await this.accessibleTripsQuery(user_id) + .select(['t.*']) + .orderBy({ 't.start_date': 'desc' }) + .execute('all', false); + } + + /** + * AT39 (`AtlasService#lastTrip`) — `SELECT t.id, t.title, t.start_date, + * t.end_date FROM trips t LEFT JOIN trip_members tm ON t.id = tm.trip_id + * WHERE (t.user_id = ? OR tm.user_id = ?) AND COALESCE(t.start_date, + * t.end_date) IS NOT NULL AND COALESCE(t.start_date, t.end_date) <= + * date('now') ORDER BY COALESCE(t.end_date, t.start_date) DESC, t.id DESC + * LIMIT 1`. `today` (`date('now')`, UTC) is resolved by the caller once + * (`todayUtc()`) and bound here — the `activeTrip(user_id, today)` + * precedent above for a literal `date('now')` comparison. + */ + async lastStartedTrip(user_id: number, today: string): Promise<{ id: number; title: string; start_date: string | null; end_date: string | null } | undefined> { + return await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), 'is not', null)) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', today)) + .orderBy((eb) => eb.fn.coalesce('t.end_date', 't.start_date'), 'desc') + .orderBy('t.id', 'desc') + .limit(1) + .executeTakeFirst(); + } + + /** + * AT47 (`AtlasService#nextTrip`, #2542) — `SELECT t.id, t.title, + * t.start_date, t.end_date FROM trips t LEFT JOIN trip_members tm ON t.id = + * tm.trip_id WHERE (t.user_id = ? OR tm.user_id = ?) AND t.start_date IS NOT + * NULL AND t.start_date > date('now') ORDER BY t.start_date ASC, t.id ASC + * LIMIT 1`. `today` is bound like {@link lastStartedTrip}'s; the legacy + * `days_until` column (`CAST(julianday(start_date) - julianday(date('now')) + * AS INTEGER)`) is computed by the caller from the same `today`. + */ + async nextUpcomingTrip(user_id: number, today: string): Promise<{ id: number; title: string; start_date: string; end_date: string | null } | undefined> { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .where('t.start_date', 'is not', null) + .where('t.start_date', '>', today) + .orderBy('t.start_date', 'asc') + .orderBy('t.id', 'asc') + .limit(1) + .executeTakeFirst(); + return row as { id: number; title: string; start_date: string; end_date: string | null } | undefined; + } + + /** + * AT42 (`AtlasService#getTravelStats`) — `SELECT COUNT(DISTINCT t.id) as + * trips, COUNT(DISTINCT d.id) as days FROM trips t LEFT JOIN days d ON + * d.trip_id = t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id WHERE + * (t.user_id = ? OR tm.user_id = ?)`. Archived trips still count (the + * legacy statement's own doc comment, `atlas.service.ts:1049-1051`) — no + * `is_archived` filter here, on purpose. + */ + async countTripsAndDaysForUser(user_id: number): Promise<{ trips: number; days: number }> { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('days as d', 'd.trip_id', 't.id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select((eb) => [eb.fn.count('t.id').distinct().as('trips'), eb.fn.count('d.id').distinct().as('days')]) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .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); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`registration-invites.service.ts::listTripsForInvite`) — + // additive, per the class's own docstring pointing at this precondition + // ("Plan 3c is the one that builds a TripsRepository") now being met. + // --------------------------------------------------------------------------- + + /** + * RI2 — `SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC` + * (the admin invite dialog's trip picker). Kysely, `PlacesRepository + * .listImportable`'s precedent: `ORDER BY … COLLATE NOCASE` is Kysely's + * own `OrderByItemBuilder.collate('nocase')`, a portable Kysely builder + * API, not a raw SQLite fragment — no `sql-functions.ts` helper needed. + */ + async listIdTitleOrderedByTitle(): Promise<{ id: number; title: string }[]> { + return await this.kysely() + .selectFrom('trips') + .select(['id', 'title']) + .orderBy('title', (ob) => ob.collate('nocase').asc()) + .execute(); + } +} + +/** {@link TripsRepository.listIdTitleOrderedByTitle}'s narrow `trips` shape. */ +interface TripIdTitleKyselyDB { + trips: { id: number; title: string }; +} + +/** {@link TripsRepository.lastStartedTrip}'s and {@link TripsRepository.nextUpcomingTrip}'s narrow `trips`/`trip_members` shape. */ +interface LastStartedTripKyselyDB { + trips: { id: number; title: string; start_date: string | null; end_date: string | null; user_id: number }; + trip_members: { trip_id: number; user_id: number }; +} + +/** {@link TripsRepository.countTripsAndDaysForUser}'s narrow `trips`/`days`/`trip_members` shape. */ +interface CountTripsAndDaysKyselyDB { + trips: { id: number; user_id: number }; + days: { id: number; trip_id: number }; + trip_members: { trip_id: number; user_id: number }; +} + +/** {@link TripsRepository.listReminderCandidates}'s row — `t.user` selected bare (not joined here), so it aliases to the physical `user_id` column per `findAccessible`'s documented precedent. */ +export interface TripReminderCandidateRow { + id: number; + title: string; + user_id: number; + reminder_days: number; + start_date: string; +} + +/** `PublicApiService.listTrips`/`getTrip`'s 8-column trip projection. */ +export interface TripSummaryProjectionRow { + id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + is_archived: number | null; + updated_at: string | null; +} + +/** {@link TripsRepository.findPublicForShare}'s projection (SH7). */ +export interface TripPublicShareRow { + id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + cover_image: string | null; + currency: string | null; +} + +/** `PublicApiService.buildTravellers`'s output row. */ +export interface TravellerUsernameRow { + username: string; + is_owner: number; +} + +/** The narrow `trips`/`users`/`trip_members` shape `listTravellerUsernames` needs. */ +interface TravellerUsernameKyselyDB { + trips: { id: number; user_id: number }; + users: { id: number; username: string }; + trip_members: { id: number; trip_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/UserNoticeDismissals.repository.ts b/server/src/db/repositories/UserNoticeDismissals.repository.ts new file mode 100644 index 000000000..b163da495 --- /dev/null +++ b/server/src/db/repositories/UserNoticeDismissals.repository.ts @@ -0,0 +1,74 @@ +import type { UserNoticeDismissals } from '../entities/UserNoticeDismissals.entity'; +import { toRow } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** SN4's projection — `notice_id`/`dismissed_app_version` (the caller already knows which user it asked for). */ +export interface UserNoticeDismissalRow { + notice_id: string; + dismissed_app_version: string | null; +} + +/** + * `user_notice_dismissals` — a genuine TWO-column composite primary key + * (`user` relation + `notice_id` text, both `.primary()` — Plan 3f Task 0's + * R5/§14 finding, confirmed against the migration DDL: + * `Migration20200101014400_system_notices.ts` + the later + * `dismissed_app_version` column addition). Every write here goes through + * `em.upsert` targeting both columns as the conflict target, never a + * synthetic `id`. `user_id` is a `persist(false)` mirror of the `user` + * relation — filtered/written through `user` throughout, never the mirror + * (the program-wide trap). + */ +export class UserNoticeDismissalsRepository extends TrekRepository { + /** + * SN4 — `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals + * WHERE user_id = ?`. `SystemNoticesService.getActiveFor` maps the result to + * a `Map` for the per-version re-show logic. + */ + async listForUser(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: ['notice_id', 'dismissed_app_version'] }); + // `dismissed_app_version` is declared optional (`?`) on the entity class + // (the `AppSettings`/`AppSettingsRow` precedent — `AppSettings + // .repository.ts#findByKeyPrefix`'s own `toRow(...) as ...Row` cast), so + // a fields-narrowed `Loaded<...>` types it as possibly-undefined even + // though a fields-narrowed read always returns the column (`null`, never + // absent) — `toRow` + the row cast bridges that gap. + return rows.map((row) => toRow(row) as UserNoticeDismissalRow); + } + + /** + * SN5 — `dismissNotice`'s `INSERT INTO user_notice_dismissals (user_id, + * notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?) ON + * CONFLICT(user_id, notice_id) DO UPDATE SET dismissed_at = + * excluded.dismissed_at, dismissed_app_version = excluded.dismissed_app_version`. + * + * Composite-PK `em.upsert` — the shape Task 0 (R5) pinned against the + * migration DDL and Task 3's `NotificationChannelPreferencesRepository + * .upsertPreference` proved first with a rendered-SQL test + * (`NCPREPO-UPSERT-SQL`), re-checked here rather than re-derived + * (`UserNoticeDismissals.repository.test.ts`'s `UNDREPO-UPSERT-SQL`). + * `onConflictFields` names the two primary-key properties by their ENTITY + * property names (`user`, not the `persist(false)` `user_id` mirror); + * `onConflictAction: 'merge'` reproduces the `ON CONFLICT ... DO UPDATE`'s + * "overwrite `dismissed_at`/`dismissed_app_version`" semantics exactly — + * there is no other non-key column for it to diverge on. + * + * Named `upsertDismissal`, not `upsert`: `TrekRepository` already declares + * (and overrides) `upsert` with MikroORM's own generic signature, so a + * method of the same name here would have to be a compatible override, not + * a differently-shaped convenience wrapper — same reason + * `AppSettingsRepository.setValue`/`NotificationChannelPreferencesRepository + * .upsertPreference` use their own names rather than `upsert`. + * + * `dismissedAt` stays the caller's raw epoch-ms integer (`Date.now()`, not + * `CURRENT_TIMESTAMP`) — this table is the one place in the whole plan + * that stores a timestamp this way; preserved exactly, not normalized to + * match every other domain's `CURRENT_TIMESTAMP` convention. + */ + async upsertDismissal(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string): Promise { + await this.upsert( + { user: userId, notice_id: noticeId, dismissed_at: dismissedAt, dismissed_app_version: dismissedAppVersion }, + { onConflictFields: ['user', 'notice_id'], onConflictAction: 'merge' }, + ); + } +} diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts new file mode 100644 index 000000000..6a37d836d --- /dev/null +++ b/server/src/db/repositories/Users.repository.ts @@ -0,0 +1,1644 @@ +import { Users } from '../entities/Users.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { coalesce, coalesceOverrideWhileSame, columnIncrementedBy, currentTimestamp, lower, lowerParam } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * The three third-party keys that belong to the instance rather than to a + * person (see `nest/settings/instance-api-keys.ts`). A closed union, never a + * dynamic column string — `getApiKeyColumn` dispatches through MikroORM's + * typed `fields` option, which only accepts a real property of `Users`, so a + * name outside this union fails `tsc` rather than reaching a query. + * + * This repository is the union's source of truth; `instance-api-keys.ts` + * imports it rather than keeping its own copy (Task 5). + */ +export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key'; + +/** + * A full `users` row (`SELECT * FROM users WHERE ...`), as the API emits it. + * Every scalar column of the entity, hand-nullability-annotated the way + * `AppSettingsRepository`/`CategoriesRepository`'s row interfaces are — + * `AssertRowKeys` below fails `tsc` the moment this list and the entity's + * scalar columns drift. + */ +export interface UserRow { + id: number; + username: string; + email: string; + password_hash: string; + role: string; + maps_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; + openweather_api_key: string | null; + avatar: string | null; + oidc_sub: string | null; + oidc_issuer: string | null; + last_login: string | null; + mfa_enabled: number | null; + mfa_secret: string | null; + mfa_backup_codes: string | null; + immich_url: string | null; + immich_access_token: string | null; + synology_url: string | null; + synology_username: string | null; + synology_password: string | null; + synology_sid: string | null; + must_change_password: number | null; + password_version: number; + feed_token: string | null; + is_guest: number; + created_at: string | null; + updated_at: string | null; + immich_api_key: string | null; + synology_skip_ssl: number; + synology_did: string | null; + first_seen_version: string; + login_count: number; + immich_auto_upload: number; + airtrail_url: string | null; + airtrail_api_key: string | null; + airtrail_allow_insecure_tls: number | null; + airtrail_write_enabled: number | null; + display_name: string | null; + immich_allow_insecure_tls: number; +} + +const _userRowKeys: AssertRowKeys = true; + +/** `AuthService.registerUser` (AU10) / `OidcService.findOrCreateUser` (O14) — the two column sets `insertUser` covers as one method (Task 1 brief's Required set: "one method, nullable oidc/avatar fields"). */ +export interface NewUserRow { + username: string; + email: string; + password_hash: string; + role: string; + first_seen_version: string; + oidc_sub?: string | null; + oidc_issuer?: string | null; + avatar?: string | null; +} + +/** + * `TripMembersService.createGuest` (TM16, `trip-members.service.ts:222-224`) — + * a credential-less guest account (#1362). Additive, NOT a widening of + * `NewUserRow` above (Plan 3b Task 1's shape, covering only the AU10/O14 + * real-account column sets): this method's `password_hash`/`role`/`is_guest` + * are fixed literals the caller can never override, which is the whole + * point — a second, narrower method keeps the "credential-less account" + * invariant IN the method rather than in caller discipline (Task 6 brief's + * explicit ruling). + */ +export interface NewGuestUserRow { + username: string; + email: string; + display_name: string; +} + +/** `UserProfileService.updateApiKeys` (UP3) — the four encrypted key columns, already resolved by the service. */ +export interface ApiKeysPatch { + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; +} + +/** `UserProfileService.updateSettings`'s bounded dynamic SET clause (UP7) — a closed key union, never a built SQL string. */ +export interface UserProfilePatch { + maps_api_key?: string | null; + openweather_api_key?: string | null; + unsplash_api_key?: string | null; + amap_api_key?: string | null; + username?: string; + email?: string; +} + +/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */ +export interface UserApiKeyColumns { + role: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; +} + +/** `UserProfileService.updateApiKeys`/`updateSettings`'s re-select (UP4/UP8). */ +export interface UserProfileWithKeys { + id: number; + username: string; + email: string; + role: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; + avatar: string | null; + mfa_enabled: number | null; +} + +/** `UserProfileService.saveAvatar`'s re-select (UP11). */ +export interface UserProfileBasic { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; +} + +/** `UserProfileService.listUsers` (UP14). */ +export interface UserDirectoryRow { + id: number; + username: string; + avatar: string | null; +} + +/** `AuthService.getCurrentUser` (AU14). */ +export interface CurrentUserRow { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; + oidc_issuer: string | null; + created_at: string | null; + mfa_enabled: number | null; + must_change_password: number | null; +} + +/** `jwt-verify.ts#verifyJwtAndLoadUser` (JV1). */ +export interface UserWithPasswordVersion { + id: number; + username: string; + email: string; + role: string; + password_version: number; +} + +/** `realtime.gateway.ts#handleConnection`'s WS-handshake row (Plan 4 Task 1). */ +export interface UserWsHandshakeRow { + id: number; + username: string; + email: string; + role: string; + mfa_enabled: number | boolean | null; + password_version: number; +} + +/** `AuthService.requestPasswordReset` (AU36). */ +export interface PasswordResetLookup { + id: number; + email: string; + password_hash: string; + oidc_sub: string | null; +} + +/** `AuthService.resetPassword`'s reset-target read (AU40). */ +export interface ResetTargetRow { + id: number; + email: string; + mfa_enabled: number | null; + mfa_secret: string | null; + mfa_backup_codes: string | null; + password_version: number; +} + +/** AD1 (`admin.service.ts#listUsers`) — the admin user-list projection. */ +export interface AdminUserListRow { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; + created_at: string | null; + updated_at: string | null; + last_login: string | null; +} + +/** AD5/AD14 (`admin.service.ts#createUser`/`updateUser`'s post-write re-select), byte-identical text at both sites. */ +export interface AdminUserSummaryRow { + id: number; + username: string; + email: string; + role: string; + created_at: string | null; + updated_at: string | null; +} + +/** AD16 (`admin.service.ts#resetUserMfa`'s pre-write read). */ +export interface AdminUserMfaRow { + id: number; + email: string; + mfa_enabled: number | null; +} + +/** AD4 (`admin.service.ts#createUser`'s write) — narrower than {@link NewUserRow}: no `first_seen_version`/`login_count`/oidc/avatar columns named, letting the entity's own defaults (`'0.0.0'`/`0`) fill them, the same values the legacy INSERT's omitted columns fell back to via the schema's `DEFAULT`. */ +export interface NewAdminUserRow { + username: string; + email: string; + password_hash: string; + role: string; +} + +/** AD11 (`admin.service.ts#updateUser`, inside `uow.transactional`) — the columns the legacy COALESCE-shaped UPDATE can touch; a key the caller omits is left unchanged, the same net effect as binding NULL into `COALESCE(?, column)`. */ +export interface AdminEditPatch { + username?: string; + email?: string; + role?: string; + password_hash?: string; + password_version?: number; +} + +export class UsersRepository extends TrekRepository { + /** + * **Ruling (Plan 3b Task 1 fix round, supersedes Plan 3a's I1 "`refresh: + * true` on every PK-only `findOne`"):** every row-out read in this + * repository (`findOne`/`find`) passes `disableIdentityMap: true` instead. + * MikroORM forks the EM with `keepTransactionContext: true`, loads inside + * that fork (so the read still sees the transaction's own uncommitted + * writes), and clears the fork afterwards + * (`node_modules/@mikro-orm/core/EntityManager.js:807-814`) — the entity + * is never added to the *request's* identity map, so it can never become a + * stale pending change-set entry that a later `flush()` (including the + * implicit one at `UnitOfWork.transactional` commit) writes back over an + * intervening `nativeUpdate`. `refresh: true` is dropped as redundant: it + * only mattered for an entity that stayed in the map, and none does now. + * See https://mikro-orm.io/docs/entity-manager#disableidentitymap and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` (B1). + * + * **Plan 3b interlude B update:** this option is now applied by the base + * class, not per call — `TrekRepository`'s `find`/`findOne` overrides + * (`_shared/trek-repository.ts`) merge `{ disableIdentityMap: true }` into + * every call's options unless the caller passes `disableIdentityMap: + * false` explicitly, so no method below names it any more. The base class + * also closes the gap the Task 1 re-review found in this mechanism + * (`task-1-rereview.md` F-R1): `disableIdentityMap: true` resolves with + * MikroORM's non-validating `getContext(false)`, so a converted read no + * longer failed closed outside a request context — every override + * validates first (`this.getEntityManager().getContext()`, the + * VALIDATING form) and only then delegates, restoring the ratchet for + * reads AND for the write paths that never validated at all (F7 INFO). + */ + async getEmail(userId: number): Promise { + const row = await this.findOne({ id: userId }, { fields: ['email'] }); + return row?.email ?? null; + } + + /** + * One of the three instance-API-key columns, selected by `name`: + * `SELECT FROM users WHERE id = ?`. `name` is the typed union above, + * not an interpolated column — MikroORM's `fields` option only accepts a + * real property of `Users`, so this can never select an arbitrary column. + * + * `disableIdentityMap: true` per the class-level ruling above. + */ + async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise { + const row = await this.findOne({ id: userId }, { fields: [name] }); + return row ? (row[name] ?? null) : null; + } + + // --------------------------------------------------------------------- + // JV1 — jwt-verify.ts + // --------------------------------------------------------------------- + + /** + * `SELECT id, username, email, role, password_version FROM users WHERE id = ?` + * + * The canonical session-invalidation read: `verifyJwtAndLoadUser` compares + * the returned `password_version` against the JWT's `pv` claim on every + * request. `disableIdentityMap: true` per the class-level ruling above. + */ + async findByIdWithPasswordVersion(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'username', 'email', 'role', 'password_version'] }, + ); + return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null; + } + + /** + * `SELECT id, username, email, role, mfa_enabled, password_version FROM + * users WHERE id = ?` (`realtime.gateway.ts#handleConnection`, Plan 4 Task + * 1). Same construction as {@link findByIdWithPasswordVersion} above (the + * canonical password-version read this class already establishes) plus + * `mfa_enabled`, which the WS handshake's own MFA gate also needs from the + * SAME row the legacy statement read — kept as one narrowed read rather + * than two separate repository calls, so a user deleted between two calls + * can't split what was one atomic check under the legacy raw statement. + */ + async findForWsHandshake(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'username', 'email', 'role', 'mfa_enabled', 'password_version'] }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + mfa_enabled: row.mfa_enabled ?? null, + password_version: row.password_version, + } + : null; + } + + // --------------------------------------------------------------------- + // AU2 / TK7 / O2 — password_version (session-invalidation gate) + // --------------------------------------------------------------------- + + /** `SELECT password_version FROM users WHERE id = ?`. `?? 0` stays in the caller (AU2/TK7/O2 each fall back differently in spirit, identically in value). */ + async getPasswordVersion(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['password_version'] }); + return row?.password_version ?? null; + } + + // --------------------------------------------------------------------- + // AU4 / AU7 / O10 — first-user / admin-role decisions + // --------------------------------------------------------------------- + + /** `SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0`. `is_guest` is `INTEGER NOT NULL DEFAULT 0` (verified in the baseline + guest-members migrations) — no row can store NULL there, so `{ is_guest: 0 }` is exact parity for the `COALESCE(is_guest, 0) = 0` guard, not an approximation. */ + async countNonGuest(): Promise { + return this.count({ is_guest: 0 }); + } + + // --------------------------------------------------------------------- + // AU6 — setup_complete + // --------------------------------------------------------------------- + + /** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */ + async findAdminNeedingPasswordChange(): Promise { + const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] }); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // AU9 — registration collision check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) + * AND COALESCE(is_guest, 0) = 0` + * + * Both operands of each comparison are folded by SQLite's own `LOWER()` + * (`lowerParam` binds the RAW value) — never a JS-lowered bind. SQLite's + * `LOWER()` is ASCII-only; `String.prototype.toLowerCase()` is + * full-Unicode, and mixing them locks out any non-ASCII cased identifier + * (program rule 18; Plan 3b Task 7 review, H1). + */ + async findIdByEmailOrUsernameCI(email: string, username: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { + $or: [ + { [lower(platform, 'email')]: lowerParam(platform, email) }, + { [lower(platform, 'username')]: lowerParam(platform, username) }, + ], + is_guest: 0, + }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // AU10 / O14 — account creation + // --------------------------------------------------------------------- + + /** + * `INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) + * VALUES (?, ?, ?, ?, ?, 0)` (AU10, local registration) and + * `INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) + * VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)` (O14, SSO registration) — ONE method + * (Task 1 brief's Required set), the `oidc_sub`/`oidc_issuer`/`avatar` + * columns nullable so AU10's caller simply omits them. `login_count` is + * always the literal `0` on insert, never a parameter, matching both + * statements exactly. + * + * `this.insert` (F2, Plan 3b Task 1 fix round; routed through the + * repository rather than `this.getEntityManager().insert(Users, …)` so it + * goes through the base class's validation — see + * `_shared/trek-repository.ts`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row — the review caught it emitting a + * full-row `UPDATE users … WHERE id = ?` against an unrelated, + * previously-read user in the same EM. `insert` fires a single native + * INSERT with no side effects on the context/identity map (the same rule + * as `AuditLogRepository.insertEntry`). Followed by a read-back + * (`findById`, `disableIdentityMap: true` by the base class's default): + * the returned row carries the generated `id` and the `defaultRaw` + * `created_at` the same way a legacy INSERT-then-reselect pair did. + */ + async insertUser(row: NewUserRow): Promise { + const id = await this.insert({ + username: row.username, + email: row.email, + password_hash: row.password_hash, + role: row.role, + first_seen_version: row.first_seen_version, + login_count: 0, + oidc_sub: row.oidc_sub ?? null, + oidc_issuer: row.oidc_issuer ?? null, + avatar: row.avatar ?? null, + }); + const inserted = await this.findById(id); + if (!inserted) { + throw new Error('insertUser: read-back after insert found no row'); + } + return inserted; + } + + // --------------------------------------------------------------------- + // AU12 / O4 — email lookup (login + OIDC email-fallback) + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` + * — AU9's sibling for AU12 (`AuthService.loginUser`, `auth.service.ts`). + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the + * RAW, untransformed email) — the legacy statement lowered BOTH operands + * in SQL, so a JS-lowered bind here would disagree with the column on + * every non-ASCII cased character (program rule 18; Plan 3b Task 7 + * review, H1: a `JOSÉ@x.com` account could register but never log back + * in). Distinct from `findByEmailLoweredBind` below, which mirrors O4's + * different legacy statement — do not collapse the two back into one + * method (D4). + */ + async findByEmailCI(email: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const user = await this.findOne({ [lower(platform, 'email')]: lowerParam(platform, email), is_guest: 0 }); + return user ? (toRow(user) as UserRow) : null; + } + + /** + * `SELECT * FROM users WHERE LOWER(email) = ? AND COALESCE(is_guest, 0) = 0` + * — O4 (`OidcService.findOrCreateUser`, `oidc.service.ts:655`), whose only + * caller JS-lowers the email itself (`oidc.service.ts:620`, + * `userInfo.email.trim().toLowerCase()`) before calling this method, + * exactly mirroring the legacy statement's `LOWER(email) = ?` (SQL + * `LOWER()` on the column, a bind the CALLER already lowered — not a + * second SQL `LOWER()` on the value side). Do not point a caller that has + * NOT already lowered its value at this method — use `findByEmailCI` + * above instead. + */ + async findByEmailLoweredBind(alreadyLoweredEmail: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const user = await this.findOne({ [lower(platform, 'email')]: alreadyLoweredEmail, is_guest: 0 }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AuthService.demoLogin (auth.service.ts:~376) — inventory-correction + // addition, Plan 3b Task 1 fix round item 7 (pre-authorised for Task 5). + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE email = ?` — case-**sensitive**, **no** guest + * filter, full row. Distinct from both `findByEmailCI` (case-insensitive + * + guest-filtered) and `findForPasswordReset` (case-sensitive but + * projected + guest-filtered): `AuthService.demoLogin` needs the exact, + * unfiltered legacy statement, including matching a guest row. + */ + async findByEmailExact(email: string): Promise { + const user = await this.findOne({ email }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AU13 / AU34 / AU35 / PK12 / O16 — login bookkeeping + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 + * WHERE id = ?` — ONE statement via `nativeUpdate` with a raw sibling-column + * increment (`columnIncrementedBy`), never a read-modify-write: two + * concurrent calls both read `login_count` from the row at commit time, not + * from a value fetched earlier, so both increments land (proven by + * `USERSREPO` `touchLastLogin` concurrency test — two concurrent calls → + * `+2`, never `+1`). + */ + async touchLastLogin(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { last_login: currentTimestamp(platform), login_count: columnIncrementedBy(platform, 'login_count', 1) }, + ); + } + + // --------------------------------------------------------------------- + // AU14 — /api/auth/me + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?` */ + async findMeRow(id: number): Promise { + const row = await this.findOne( + { id }, + { + fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'], + }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + avatar: row.avatar ?? null, + oidc_issuer: row.oidc_issuer ?? null, + created_at: row.created_at ?? null, + mfa_enabled: row.mfa_enabled ?? null, + must_change_password: row.must_change_password ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // AU15 — current-password check + // --------------------------------------------------------------------- + + /** `SELECT password_hash, password_version FROM users WHERE id = ?` */ + async getPasswordHashAndVersion(id: number): Promise<{ password_hash: string; password_version: number } | null> { + const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'] }); + return row ? { password_hash: row.password_hash, password_version: row.password_version } : null; + } + + // --------------------------------------------------------------------- + // AU16 / AU43 — password change / reset + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET password_hash = ?, must_change_password = 0, + * password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + * (AU16 `changePassword`, AU43 `resetPassword` — identical statement). + */ + async setPassword(id: number, passwordHash: string, passwordVersion: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { password_hash: passwordHash, must_change_password: 0, password_version: passwordVersion, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU19 / O7 — last-admin guard + // --------------------------------------------------------------------- + + /** `SELECT COUNT(*) as count FROM users WHERE role = 'admin'` */ + async countAdmins(): Promise { + return this.count({ role: 'admin' }); + } + + // --------------------------------------------------------------------- + // AU20 / AU22 — admin gate + // --------------------------------------------------------------------- + + /** `SELECT role FROM users WHERE id = ?` */ + async getRole(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['role'] }); + return row?.role ?? null; + } + + // --------------------------------------------------------------------- + // AU23 / AU27 / MP2 — MFA state + // --------------------------------------------------------------------- + + /** + * `SELECT mfa_enabled FROM users WHERE id = ?` + * + * Returns the ROW, not the bare column: `mfa-policy.guard.ts` (MP2) needs + * to tell "no row for this id" (`null` — the user vanished mid-request, + * `MfaPolicyGuard` lets the request through) apart from "row present with + * `mfa_enabled` unset" (`{ mfa_enabled: null }` — still governed by the + * policy) — the same distinction the legacy `if (!row) return true;` + * before `row.mfa_enabled === 1` made. A bare `T | null` return would + * collapse both into the same value. + */ + async getMfaEnabled(id: number): Promise<{ mfa_enabled: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['mfa_enabled'] }); + return row ? { mfa_enabled: row.mfa_enabled ?? null } : null; + } + + // --------------------------------------------------------------------- + // AU28 — enable MFA + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, + * updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `secret`/`codes` arrive + * already encrypted/hashed — this repository never touches either. + */ + async enableMfa(id: number, encryptedSecret: string, backupCodes: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { mfa_enabled: 1, mfa_secret: encryptedSecret, mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU30 / AU32 / PK4 / PK10 / O15 — full-row reads + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE id = ?` — the full row, reused by every + * caller that needs more than a handful of columns (AU30 `disableMfa`, + * AU32 `verifyMfaLogin`, PK4 `passkeyRegisterOptions`, PK10 + * `passkeyLoginVerify`, O15 `findOrCreateUser`'s post-transaction re-select). + * `disableIdentityMap: true` per the class-level ruling above. + */ + async findById(id: number): Promise { + const user = await this.findOne({ id }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AU31 — disable MFA + // --------------------------------------------------------------------- + + /** `UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async disableMfa(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { mfa_enabled: 0, mfa_secret: null, mfa_backup_codes: null, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU33 / AU44 — backup-code burn + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP + * WHERE id = ?` (AU33, `verifyMfaLogin`'s backup-code branch — inside the + * same transaction as `touchLastLogin`). + */ + async setBackupCodesAndTouch(id: number, backupCodes: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) }); + } + + /** + * `UPDATE users SET mfa_backup_codes = ? WHERE id = ?` (AU44, + * `resetPassword`'s conditional backup-code splice — no `updated_at`, + * unlike AU33's otherwise-identical statement; two methods, byte-exact + * parity per statement). + */ + async setBackupCodes(id: number, backupCodes: string): Promise { + await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes }); + } + + // --------------------------------------------------------------------- + // AU36 — password-reset request lookup + // --------------------------------------------------------------------- + + /** + * `SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? + * AND COALESCE(is_guest, 0) = 0` — case-SENSITIVE on `email`, unlike + * every other email lookup in this repository (no `LOWER()` in the legacy + * statement here); kept exactly as written, not "fixed" to match the + * others (parity is law). + */ + async findForPasswordReset(email: string): Promise { + const row = await this.findOne( + { email, is_guest: 0 }, + { fields: ['id', 'email', 'password_hash', 'oidc_sub'] }, + ); + return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null; + } + + // --------------------------------------------------------------------- + // AU40 — reset-target read + // --------------------------------------------------------------------- + + /** `SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?` */ + async findResetTarget(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'] }, + ); + return row + ? { + id: row.id, + email: row.email, + mfa_enabled: row.mfa_enabled ?? null, + mfa_secret: row.mfa_secret ?? null, + mfa_backup_codes: row.mfa_backup_codes ?? null, + password_version: row.password_version, + } + : null; + } + + // --------------------------------------------------------------------- + // PK15 — re-auth before passkey deletion + // --------------------------------------------------------------------- + + /** + * `SELECT password_hash FROM users WHERE id = ?` + * + * The `?? null` fallback's own "row present but `password_hash` itself + * null" branch is unreachable: `password_hash TEXT NOT NULL` in the + * baseline schema (never relaxed by a later migration) — the same + * defensive-but-unreachable shape as `countNonGuest`'s `COALESCE(is_guest, + * 0)` above. The `?.`/`??` combination still earns its keep for the + * "row missing" case (`?.` alone would leave `undefined`, not `null`). + */ + async getPasswordHash(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['password_hash'] }); + return row?.password_hash ?? null; + } + + // --------------------------------------------------------------------- + // PK17 — admin passkey-reset target + // --------------------------------------------------------------------- + + /** `SELECT id, email FROM users WHERE id = ?` */ + async findIdAndEmail(id: number): Promise<{ id: number; email: string } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'email'] }); + return row ? { id: row.id, email: row.email } : null; + } + + // --------------------------------------------------------------------- + // O3 — OIDC primary identity match + // --------------------------------------------------------------------- + + /** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */ + async findByOidcIdentity(sub: string, issuer: string): Promise { + const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // O5 / O6 — OIDC identity linking + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?` (O5, the + * email-matched/unlinked/verified-email branch; O6, the provider-switch + * branch — identical statement, two call sites, no `updated_at`). + */ + async linkOidcIdentity(id: number, sub: string, issuer: string): Promise { + await this.nativeUpdate({ id }, { oidc_sub: sub, oidc_issuer: issuer }); + } + + // --------------------------------------------------------------------- + // O8 — claims-based role change + // --------------------------------------------------------------------- + + /** `UPDATE users SET role = ? WHERE id = ?` — no `updated_at` (legacy statement). */ + async setRole(id: number, role: string): Promise { + await this.nativeUpdate({ id }, { role }); + } + + // --------------------------------------------------------------------- + // O9 — OIDC avatar sync + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET avatar = ? WHERE id = ?` — no `updated_at`, unlike + * `setAvatar` (UP10/UP13) below, which is the profile-upload path's + * otherwise-identical statement. Two methods, byte-exact parity per + * statement (same rule as `setBackupCodes`/`setBackupCodesAndTouch`). + */ + async setAvatarRaw(id: number, avatar: string | null): Promise { + await this.nativeUpdate({ id }, { avatar }); + } + + // --------------------------------------------------------------------- + // O12 / UP5 — username collision checks + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? + * AND COALESCE(is_guest, 0) = 0` (UP5, `updateSettings`'s own-username + * collision check). + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. + */ + async findIdByUsernameCI(username: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'username')]: lowerParam(platform, username), id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** + * `SELECT id FROM users WHERE LOWER(username) = LOWER(?)` (O12, OIDC + * derived-username collision avoidance — no `excludeId`, no guest filter: + * a genuinely different WHERE than UP5's, so a separate method per D4 + * rather than an optional parameter that would silently change UP5's + * shape. + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. The only caller sanitises the username to + * `[a-zA-Z0-9_.-]` before calling (`oidc.service.ts`), so this cannot + * diverge in practice today; fixed anyway so the guarantee lives in the + * method, not the caller. + */ + async findIdByUsernameCIAny(username: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'username')]: lowerParam(platform, username) }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // UP1 — API-key columns read (`currentKeys`) + // --------------------------------------------------------------------- + + /** `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` */ + async getApiKeyColumns(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] }, + ); + return row + ? { + role: row.role, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + amap_api_key: row.amap_api_key ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // UP2 — single-key (maps) update + // --------------------------------------------------------------------- + + /** `UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async updateMapsKey(id: number, encryptedMapsApiKey: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { maps_api_key: encryptedMapsApiKey, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP3 — four-key update + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET maps_api_key = ?, openweather_api_key = ?, + * unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP + * WHERE id = ?`. The per-column "encrypt the new value or keep the + * current one" branching (a deleted-row race degrading to a 0-row UPDATE, + * not a crash) is the service's, per D4 — this writes exactly the four + * final values it is given. + */ + async updateApiKeys(id: number, keys: ApiKeysPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...keys, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP4 / UP8 — profile-with-keys re-select + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?` */ + async findProfileWithKeys(id: number): Promise { + const row = await this.findOne( + { id }, + { + fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'], + }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + amap_api_key: row.amap_api_key ?? null, + avatar: row.avatar ?? null, + mfa_enabled: row.mfa_enabled ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // UP6 — email collision check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0` + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. + */ + async findIdByEmailCI(email: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'email')]: lowerParam(platform, email), id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // UP7 — bounded dynamic-column profile patch + // --------------------------------------------------------------------- + + /** + * `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP + * WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed + * set of 6 possible literal column names, never here: `patchProfile` takes + * only the already-decided final columns as a typed partial and writes + * them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a + * second statement shape for the identical intent) — the entity-API + * equivalent of the legacy's dynamic `SET` list, without ever building SQL + * text. `updated_at` is always stamped, matching the legacy: this method's + * only caller (`UserProfileService.updateSettings`) calls it only when at + * least one field changed (`updates.length > 0`), the same guard the + * legacy statement was built under. + */ + async patchProfile(id: number, changes: UserProfilePatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...changes, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP9 / UP12 — avatar read (before cleanup/replace) + // --------------------------------------------------------------------- + + /** `SELECT avatar FROM users WHERE id = ?` */ + async getAvatar(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['avatar'] }); + return row?.avatar ?? null; + } + + // --------------------------------------------------------------------- + // UP10 / UP13 — avatar write + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + * (UP10 `saveAvatar` with a filename, UP13 `deleteAvatar` with `NULL` — + * same statement shape, `avatar` nullable either way). + */ + async setAvatar(id: number, avatar: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { avatar, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP11 — post-avatar-write re-select + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */ + async findProfileBasic(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'] }); + return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // UP14 — user directory (trip member / contributor pickers) + // --------------------------------------------------------------------- + + /** `SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC` */ + async listOthersNonGuest(excludeId: number): Promise { + const rows = await this.find( + { id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } }, + ); + return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null })); + } + + // --------------------------------------------------------------------- + // UP15 — key-validation probe + // --------------------------------------------------------------------- + + /** `SELECT role, openweather_api_key FROM users WHERE id = ?` */ + async getRoleAndWeatherKey(id: number): Promise<{ role: string; openweather_api_key: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'] }); + return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM3 — TripMembersService.listMembers's owner row + // --------------------------------------------------------------------- + + /** + * `SELECT id, COALESCE(display_name, username) AS username, email, avatar + * FROM users WHERE id = ?` (`trip-members.service.ts:129`, TM3). The + * legacy statement's own docstring records this COALESCE as a quirk fix on + * top of the 1:1 move (the ORIGINAL raw statement read the bare + * `username`) — kept exactly as the code already stands, not re-litigated. + */ + async findOwnerSummary(id: number): Promise<{ id: number; username: string; email: string; avatar: string | null } | null> { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('u') + .select(['u.id', coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.email', 'u.avatar']) + .where({ id }) + .execute<{ id: number; username: string; email: string; avatar: string | null } | undefined>('get', false); + return row ? { id: row.id, username: row.username, email: row.email, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM4 — TripMembersService.addMember's invite-target lookup + // --------------------------------------------------------------------- + + /** + * `SELECT id, username, email, avatar FROM users WHERE (email = ? OR + * username = ?) AND COALESCE(is_guest, 0) = 0` (`trip-members.service.ts:142-144`, + * TM4) — case-SENSITIVE (unlike `findByEmailCI`/`findIdByEmailCI` above; + * kept exactly as written, program rule 18 only applies where the legacy + * statement itself folds case), guests excluded so a trip-scoped guest can + * never be resolved (and re-attached to another trip) through the invite + * box. `identifier` is bound to both sides — the caller passes the SAME + * already-trimmed string for both the email and username branch, matching + * the legacy statement's `identifier.trim()` bound twice. `is_guest: 0` is + * exact parity for `COALESCE(is_guest, 0) = 0` (`countNonGuest`'s + * docstring above: the column is `NOT NULL DEFAULT 0`, so no row can store + * `NULL` there). + */ + async findInvitableByEmailOrUsername(identifier: string): Promise<{ id: number; username: string; email: string; avatar: string | null } | null> { + const row = await this.findOne( + { $or: [{ email: identifier }, { username: identifier }], is_guest: 0 }, + { fields: ['id', 'username', 'email', 'avatar'] }, + ); + return row ? { id: row.id, username: row.username, email: row.email, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM10 — TripMembersService.transferOwnership's new-owner guest check + // --------------------------------------------------------------------- + + /** `SELECT id, email, is_guest FROM users WHERE id = ?` (`trip-members.service.ts:185`, TM10) — guest → 400 `'Cannot transfer ownership to a guest'` (caller's decision). */ + async findIdEmailGuest(id: number): Promise<{ id: number; email: string; is_guest: number } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'email', 'is_guest'] }); + return row ? { id: row.id, email: row.email, is_guest: row.is_guest } : null; + } + + // --------------------------------------------------------------------- + // TM16 — TripMembersService.createGuest (Plan 3c Task 6, security-sensitive) + // --------------------------------------------------------------------- + + /** + * `INSERT INTO users (username, email, password_hash, role, is_guest, + * display_name) VALUES (?, ?, '', 'user', 1, ?)` (`trip-members.service.ts:222-224`, + * TM16) — security-sensitive: creates a credential-less account (#1362). + * `password_hash`/`role`/`is_guest` are fixed literals, never a caller + * input (see `NewGuestUserRow`'s docstring above for why this is its own + * method rather than a widened `insertUser`). Returns the generated id + * only — the legacy caller reads `res.lastInsertRowid` and never re-selects + * the row. + */ + async insertGuest(row: NewGuestUserRow): Promise { + return this.insert({ + username: row.username, + email: row.email, + password_hash: '', + role: 'user', + is_guest: 1, + display_name: row.display_name, + }); + } + + // --------------------------------------------------------------------- + // TM19 — TripMembersService.renameGuest (security-sensitive) + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE + * id = ? AND is_guest = 1` (`trip-members.service.ts:248`, TM19) — + * security-sensitive: the `AND is_guest = 1` is a SECOND guard (belt and + * braces) on top of `TripMembersRepository.isGuestOfTrip`'s trip-scoping + * check that runs before this is ever called (#1362) — pinned by a test + * that this method, called directly on a real non-guest id, updates the + * row count of exactly zero rows. + */ + async renameGuest(id: number, displayName: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id, is_guest: 1 }, { display_name: displayName, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // TM20 — TripMembersService.deleteGuest (security-sensitive: account erasure) + // --------------------------------------------------------------------- + + /** + * `DELETE FROM users WHERE id = ? AND is_guest = 1` (`trip-members.service.ts:268`, + * TM20) — security-sensitive: account erasure, the same belt-and-braces + * `is_guest = 1` predicate as `renameGuest` above. Cascades `trip_members` + * and every assignment/budget/packing join via the entity's FK delete + * rules, unchanged by this conversion. + */ + async deleteGuest(id: number): Promise { + await this.nativeDelete({ id, is_guest: 1 }); + } + + // --------------------------------------------------------------------- + // UC11 — account erasure (Plan 3b Task 1 review, "contract gaps" item 2: + // named in the plan's Task 1 bullet, deliberately deferred to Task 5 — + // the ONE pre-authorised UsersRepository addition this task makes). + // --------------------------------------------------------------------- + + /** + * `DELETE FROM users WHERE id = ?` — `UserCleanupService.deleteUserCompletely`'s + * final statement, the root of its 13-table transaction + * (**security-sensitive**: account erasure). Named `deleteById`, not + * `remove`/`delete`: `remove` is `EntityManager`'s (persist-marking, + * needs-a-`flush()`), not `EntityRepository`'s — MikroORM 7.2.1's + * `EntityRepository` has no `remove` method at all (Task 7 review, L1; + * verified against the installed `.d.ts`, not assumed — that is why + * `OauthClientsRepository.remove`/`TagsRepository.remove`/ + * `CategoriesRepository.remove` compile as their own, unrelated methods). + * `deleteById` is chosen for naming consistency with this program's other + * native, immediate deletes (`McpTokensRepository.deleteById`), not to + * avoid a compiler conflict that does not exist. + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------- + // Plan 3d Task 5 (`FeedsService`) — additive: the all-trips ICS feed + // token's `users` half (FD5–FD8, FD10; inventory §5). `id`/`token` are + // always real values here — no raw-bind seam on this repository's newest + // methods (`FeedsService`'s callers already resolve `userId` off + // `@CurrentUser()`/the JWT, never off a route param). + // --------------------------------------------------------------------- + + /** FD5 (`feeds.service.ts::getUserToken`, also the probe inside `generateUserToken`) — `SELECT feed_token FROM users WHERE id = ?`. */ + async getFeedToken(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['feed_token'] }); + return row?.feed_token ?? null; + } + + /** + * FD6/FD7/FD8 (`generateUserToken`/`rotateUserToken`/`disableUserToken`) — + * ONE method for the three: `UPDATE users SET feed_token = ? WHERE id = + * ?`, `token: null` for disable. Unlike the trip token's + * `setFeedTokenIfReachable`, there is no reachability predicate to fold in + * here — the legacy statement is scoped by `id` alone (the acting user's + * own row, already resolved from the JWT), so a plain `nativeUpdate` + * suffices. + */ + async setFeedToken(id: number, token: string | null): Promise { + await this.nativeUpdate({ id }, { feed_token: token }); + } + + /** + * FD10 (`feeds.service.ts::buildUserIcs`) — `SELECT id, username FROM + * users WHERE feed_token = ?`. The anonymous all-trips credential lookup — + * same shape as `TripsRepository.findIdByFeedToken` (FD9): `token` is + * untrusted, and `idx_users_feed_token`'s partial UNIQUE index (`WHERE + * feed_token IS NOT NULL`) is what makes a disabled feed's old token 404 + * instead of matching a row whose column happens to be NULL. + */ + async findIdAndUsernameByFeedToken(token: string): Promise<{ id: number; username: string } | undefined> { + const row = await this.findOne({ feed_token: token }, { fields: ['id', 'username'] }); + return row ? { id: row.id, username: row.username } : undefined; + } + + // --------------------------------------------------------------------- + // Plan 3e Task 7 (memories' provider half) — additive: the Immich/Synology + // encrypted-credential statements (IM1-5/SY1-8, inventory §7d, R6). Every + // value stays exactly what the SERVICE (`ImmichService`/`SynologyService`) + // hands this repository — encrypted, plaintext or null, at the same point + // in the pipeline the raw SQL saw it. This repository never calls + // `encrypt_api_key`/`decrypt_api_key`/`maybe_encrypt_api_key` itself. + // --------------------------------------------------------------------- + + /** + * IM1 (`ImmichService.getImmichCredentials`) — `SELECT immich_url, + * immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?` + * (the TLS switch joined the read with #2475). + */ + async getImmichCredentials( + id: number, + ): Promise<{ immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key', 'immich_allow_insecure_tls'] }); + return row + ? { + immich_url: row.immich_url ?? null, + immich_api_key: row.immich_api_key ?? null, + immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null, + } + : null; + } + + /** JV1 (`JourneyService.immichAutoUploadEnabled`) — `SELECT immich_auto_upload FROM users WHERE id = ?`. */ + async getImmichAutoUpload(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['immich_auto_upload'] }); + return row?.immich_auto_upload ?? null; + } + + /** + * IM2 (`ImmichService.getConnectionSettings`'s prefs read) — `SELECT + * immich_auto_upload, immich_allow_insecure_tls FROM users WHERE id = ?` + * (the TLS switch joined the read with #2475). + */ + async getImmichConnectionPrefs( + id: number, + ): Promise<{ immich_auto_upload: number | null; immich_allow_insecure_tls: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['immich_auto_upload', 'immich_allow_insecure_tls'] }); + return row + ? { immich_auto_upload: row.immich_auto_upload ?? null, immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null } + : null; + } + + /** IM3 (`ImmichService.setImmichAutoUpload`) — `UPDATE users SET immich_auto_upload = ? WHERE id = ?`. */ + async setImmichAutoUpload(id: number, enabled: number): Promise { + await this.nativeUpdate({ id }, { immich_auto_upload: enabled }); + } + + /** + * IM4 (`ImmichService.saveImmichSettings`'s URL branch) — `UPDATE users SET + * immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = CASE WHEN + * immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE + * COALESCE(?, 0) END WHERE id = ?` (#2475). + * + * One statement, as the legacy write was: SET expressions read the row as + * it was, so the CASE compares the STORED url with the new one and decides + * the switch inside the UPDATE itself (`coalesceOverrideWhileSame`). + * `allow_insecure_tls` null keeps the stored choice while the URL stays the + * same, and a new URL without an explicit value starts off. + */ + async setImmichSettings( + id: number, + immich_url: string, + immich_api_key: string | null, + allow_insecure_tls: number | null, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + immich_url, + immich_api_key, + immich_allow_insecure_tls: coalesceOverrideWhileSame(platform, allow_insecure_tls, 'immich_allow_insecure_tls', 'immich_url', immich_url), + }, + ); + } + + /** + * IM5 (`ImmichService.saveImmichSettings`'s disconnect branch) — `UPDATE + * users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 0 + * WHERE id = ?` with a `null` URL: disconnecting always turns the switch off. + */ + async clearImmichSettings(id: number, immich_api_key: string | null): Promise { + await this.nativeUpdate({ id }, { immich_url: null, immich_api_key, immich_allow_insecure_tls: 0 }); + } + + /** + * SY1 (`SynologyService._readSynologyUser`) — `SELECT synology_url, + * synology_username, synology_password, synology_sid, synology_did, + * synology_skip_ssl FROM users WHERE id = ?`, column-filtered in JS by the + * legacy code. This method selects only the requested columns (MikroORM's + * typed `fields`) rather than all six and filtering after — the RESULT is + * identical (only the requested keys populated, matching the legacy + * `filtered` object exactly; an unrequested key is `undefined`, same as + * the legacy object never having had it set). `null` means no such user + * row (SY1's "User not found" branch). + * + * Explicit per-column `if` guards, not a generic `Pick` + + * indexed-assignment loop: MikroORM's `fields` option types against + * `AutoPath`, which does not resolve for a free type + * parameter `K` the way it does for the concrete literal union used here + * (the same reason `getApiKeyColumn` above stays non-generic); an + * indexed-assignment loop over a union key has the identical problem on + * the write side. Six `if`s reads worse but needs neither a generic nor a + * cast. + */ + async getSynologyFields(id: number, columns: SynologyUserColumn[]): Promise { + const row = await this.findOne({ id }, { fields: columns }); + if (!row) return null; + const result: SynologyFieldsRow = {}; + if (columns.includes('synology_url')) result.synology_url = row.synology_url ?? null; + if (columns.includes('synology_username')) result.synology_username = row.synology_username ?? null; + if (columns.includes('synology_password')) result.synology_password = row.synology_password ?? null; + if (columns.includes('synology_sid')) result.synology_sid = row.synology_sid ?? null; + if (columns.includes('synology_did')) result.synology_did = row.synology_did ?? null; + if (columns.includes('synology_skip_ssl')) result.synology_skip_ssl = row.synology_skip_ssl; + return result; + } + + /** SY2 (`SynologyService._clearSynologySID`) — `UPDATE users SET synology_sid = NULL WHERE id = ?`. */ + async clearSynologySID(id: number): Promise { + await this.nativeUpdate({ id }, { synology_sid: null }); + } + + /** SY3 (`SynologyService._clearSynologySession`) — `UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?`. */ + async clearSynologySession(id: number): Promise { + await this.nativeUpdate({ id }, { synology_sid: null, synology_did: null }); + } + + /** + * SY4/SY7 (`SynologyService._getSynologySession`'s session-refresh write, + * `testSynologyConnection`'s sid persist) — byte-identical `UPDATE users + * SET synology_sid = ? WHERE id = ?` at both legacy sites, one method (D4). + */ + async setSynologySid(id: number, synology_sid: string): Promise { + await this.nativeUpdate({ id }, { synology_sid }); + } + + /** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */ + async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise { + await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl }); + } + + /** SY6 (`SynologyService.getSynologyStatus`) — `SELECT synology_username FROM users WHERE id = ?`. */ + async getSynologyUsername(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['synology_username'] }); + return row?.synology_username ?? null; + } + + /** SY8 (`SynologyService.testSynologyConnection`'s did persist) — `UPDATE users SET synology_did = ? WHERE id = ?`. */ + async setSynologyDid(id: number, synology_did: string): Promise { + await this.nativeUpdate({ id }, { synology_did }); + } + + // --------------------------------------------------------------------- + // UM11 — UnifiedMemoriesService._notifySharedTripPhotos + // --------------------------------------------------------------------- + + /** UM11 — `SELECT username, email FROM users WHERE id = ?`, the shared-photos notification's actor lookup. */ + async findUsernameEmail(id: number): Promise<{ username: string; email: string } | undefined> { + const row = await this.findOne({ id }, { fields: ['username', 'email'] }); + return row ? { username: row.username, email: row.email } : undefined; + } + + // --------------------------------------------------------------------- + // Plan 3f Task 4 (MS2 — mailer.service.ts#getUserEmail) — additive. + // --------------------------------------------------------------------- + + /** + * MS2 — `SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0` + * — defense-in-depth (#1362 class): a guest's synthetic email must never be + * emailed. `is_guest: 0` is exact parity for the `COALESCE(is_guest, 0) = 0` + * guard (`countNonGuest`'s docstring above: the column is `NOT NULL DEFAULT + * 0`, so no row can store `NULL` there). + */ + async getEmailNonGuest(id: number): Promise { + const row = await this.findOne({ id, is_guest: 0 }, { fields: ['email'] }); + return row?.email ?? null; + } + + // --------------------------------------------------------------------- + // Plan 3h Task 2 (`CollectionsService.sendInvite`) — additive. + // --------------------------------------------------------------------- + + /** CL79 (`sendInvite`) — `SELECT id, username FROM users WHERE id = ?`. */ + async findIdUsername(id: number): Promise<{ id: number; username: string } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'username'] }); + return row ? { id: row.id, username: row.username } : null; + } + + // --------------------------------------------------------------------- + // Plan 3h Task 4 (`AirtrailService` — R7, the encrypt-in-service + // discipline identical to Task 3's `DawarichConnectionsRepository`: + // every value below is exactly what the SERVICE hands this repository — + // encrypted, plaintext or null, at the same point in the pipeline the + // raw SQL saw it. This repository never calls + // maybe_encrypt_api_key/decrypt_api_key itself. + // --------------------------------------------------------------------- + + /** ATC1 (`airtrail.service.ts#readRow`) — `SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?`. */ + async getAirtrailConnRow(id: number): Promise<{ + airtrail_url: string | null; + airtrail_api_key: string | null; + airtrail_allow_insecure_tls: number | null; + airtrail_write_enabled: number | null; + } | null> { + const row = await this.findOne( + { id }, + { fields: ['airtrail_url', 'airtrail_api_key', 'airtrail_allow_insecure_tls', 'airtrail_write_enabled'] }, + ); + return row + ? { + airtrail_url: row.airtrail_url ?? null, + airtrail_api_key: row.airtrail_api_key ?? null, + airtrail_allow_insecure_tls: row.airtrail_allow_insecure_tls ?? null, + airtrail_write_enabled: row.airtrail_write_enabled ?? null, + } + : null; + } + + /** ATC2 (`airtrail.service.ts#isAirtrailWriteEnabled`) — `SELECT airtrail_write_enabled FROM users WHERE id = ?`. */ + async getAirtrailWriteEnabled(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['airtrail_write_enabled'] }); + return row?.airtrail_write_enabled ?? null; + } + + /** + * ATC3 (`airtrail.service.ts#saveSettings`, newKey branch) — `UPDATE users + * SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls + * = ?, airtrail_write_enabled = ? WHERE id = ?`. **No transaction** — the + * plan's Risks section flags this as a pre-existing asymmetry with + * `DawarichConnectionsRepository`'s equivalent `saveSettings` (which DOES + * wrap its writes in `uow.transactional`): preserved exactly, not + * "fixed" to match Dawarich's shape. + */ + async setAirtrailSettingsWithKey(id: number, url: string | null, apiKey: string, allowInsecureTls: number, writeEnabled: number): Promise { + await this.nativeUpdate( + { id }, + { airtrail_url: url, airtrail_api_key: apiKey, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }, + ); + } + + /** + * ATC4 (`airtrail.service.ts#saveSettings`, no-newKey branch) — `UPDATE + * users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, + * airtrail_write_enabled = ? WHERE id = ?`. Same no-transaction asymmetry + * as {@link setAirtrailSettingsWithKey} — preserved, not fixed. + */ + async setAirtrailSettings(id: number, url: string | null, allowInsecureTls: number, writeEnabled: number): Promise { + await this.nativeUpdate({ id }, { airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }); + } + + /** + * ATC5 (`airtrail.service.ts#saveSettings`, URL-cleared branch) — `UPDATE + * users SET airtrail_api_key = NULL WHERE id = ?`. **SECURITY**: + * credential scrub — a cleared URL with no key left makes the connection + * meaningless, so the key is dropped too. Same no-transaction asymmetry + * as the two methods above — this is a THIRD, separate statement in the + * legacy `saveSettings` (its own `if (!trimmedUrl)` branch), not folded + * into {@link setAirtrailSettings}. + */ + async clearAirtrailApiKey(id: number): Promise { + await this.nativeUpdate({ id }, { airtrail_api_key: null }); + } + + // --------------------------------------------------------------------- + // Plan 3i Task 1 (`AdminService` — AD1-AD17, admin's user-CRUD surface). + // Every statement not already covered by an existing method above + // (`findById` = AD6, `getRole` = AD9, `countAdmins` = AD10, `disableMfa` + // = AD17, `findIdAndEmail` = AD15 — all reused as-is, no new method). + // --------------------------------------------------------------------- + + /** + * AD1 (`listUsers`) — `SELECT id, username, email, role, avatar, + * created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, + * 0) = 0 ORDER BY created_at DESC`. `is_guest: 0` is exact parity for the + * COALESCE guard (same reasoning as `countNonGuest` above — the column is + * `NOT NULL DEFAULT 0`). + */ + async listForAdmin(): Promise { + const rows = await this.find( + { is_guest: 0 }, + { + fields: ['id', 'username', 'email', 'role', 'avatar', 'created_at', 'updated_at', 'last_login'], + orderBy: { created_at: 'desc' }, + }, + ); + return rows.map((row) => ({ + id: row.id, + username: row.username, + email: row.email, + role: row.role, + avatar: row.avatar ?? null, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, + last_login: row.last_login ?? null, + })); + } + + /** + * AD2 (`createUser`) — `SELECT id FROM users WHERE username = ? AND + * COALESCE(is_guest, 0) = 0`. Case-**sensitive** (plain equality, no + * `LOWER()`) — distinct from `findIdByUsernameCIAny`'s folded comparison, + * which is a genuinely different legacy statement. + */ + async findIdByUsernameExact(username: string): Promise { + const row = await this.findOne({ username, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** AD3 (`createUser`) — `SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, same reasoning as {@link findIdByUsernameExact}. */ + async findIdByEmailExact(email: string): Promise { + const row = await this.findOne({ email, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** + * AD4 (`createUser`'s write) — `INSERT INTO users (username, email, + * password_hash, role) VALUES (?, ?, ?, ?)`. Only these four columns are + * named, matching the legacy statement's own column list exactly; every + * other column (`first_seen_version`, `login_count`, …) is left to the + * entity's own default the same way the legacy INSERT left them to the + * schema's `DEFAULT` — not reused from {@link insertUser} (AU10/O14), + * whose `NewUserRow` requires `first_seen_version` as a caller-supplied + * value, a column this statement never named at all. Returns the + * generated id only (no re-select) — AD5 is a separate, distinct + * statement handled by {@link findAdminSummary}. + */ + async insertAdminCreatedUser(row: NewAdminUserRow): Promise { + return await this.insert({ + username: row.username, + email: row.email, + password_hash: row.password_hash, + role: row.role, + }); + } + + /** + * AD5/AD14 (`createUser`'s post-insert re-select, `updateUser`'s + * post-update re-select) — `SELECT id, username, email, role, created_at, + * updated_at FROM users WHERE id = ?`, byte-identical text at both sites, + * one method (D4). + */ + async findAdminSummary(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'created_at', 'updated_at'] }); + return row + ? { id: row.id, username: row.username, email: row.email, role: row.role, created_at: row.created_at ?? null, updated_at: row.updated_at ?? null } + : null; + } + + /** + * AD7 (`updateUser`) — `SELECT id FROM users WHERE username = ? AND id != + * ? AND COALESCE(is_guest, 0) = 0`. Case-**sensitive**, excludes the + * user's own row — distinct from `findIdByUsernameCI`'s `LOWER()`-folded, + * otherwise identically-shaped statement (UP5's own-username check). + */ + async findIdByUsernameExactExcluding(username: string, excludeId: number): Promise { + const row = await this.findOne({ username, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** AD8 (`updateUser`) — `SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, excludes self, same reasoning as {@link findIdByUsernameExactExcluding}. */ + async findIdByEmailExactExcluding(email: string, excludeId: number): Promise { + const row = await this.findOne({ email, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** + * AD11 (`updateUser`, inside `uow.transactional` alongside AD12/AD13) — + * `UPDATE users SET username=COALESCE(?,username), email=COALESCE(?,email), + * role=COALESCE(?,role), password_hash=COALESCE(?,password_hash), + * password_version=COALESCE(?,password_version), updated_at=CURRENT_TIMESTAMP + * WHERE id=?`. The legacy binds a NULL parameter for "no change" — a + * `nativeUpdate` that simply omits an unchanged key from `patch` has the + * identical net effect (coalesceOverride: `COALESCE(?, column)` — the new + * value wins when the caller supplies a key, the existing column wins + * when it is left out; `coalesceParam`'s `COALESCE(column, ?)` is the + * opposite direction — see `dialect/sql-functions.ts`) — + * the caller decides which keys to include, the same discipline + * `patchProfile` (UP7) already uses for this table. `updated_at` is + * ALWAYS stamped, even when `patch` is `{}` — matching the legacy + * statement's own unconditional `updated_at = CURRENT_TIMESTAMP` (every + * `updateUser` call runs this UPDATE regardless of what changed). + */ + async applyAdminEdit(id: number, patch: AdminEditPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) }); + } + + /** + * AD16 (`resetUserMfa`'s pre-write read) — `SELECT id, email, mfa_enabled + * FROM users WHERE id = ?`. Distinct from `getMfaEnabled` (MP2/AU23/AU27), + * which selects only `mfa_enabled` for a different caller. + */ + async findIdEmailMfaEnabled(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] }); + return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null; + } + + // --------------------------------------------------------------------- + // HR1 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts#getUser`) + // --------------------------------------------------------------------- + + /** + * HR1 — `SELECT id, username, display_name, avatar FROM users WHERE id = + * ?`. Returned RAW to the calling plugin (the RPC handler's own access + * gate runs first — self, or `TripsRepository.sharesTripWith`) — this + * repository method itself does no shaping, matching `findProfileBasic`'s + * narrower-but-similarly-shaped precedent above. + */ + async findPublicIdentity(id: number): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'display_name', 'avatar'] }); + return row ? { id: row.id, username: row.username, display_name: row.display_name ?? null, avatar: row.avatar ?? null } : null; + } +} + +/** + * The six Synology credential/session columns `_readSynologyUser` reads + * selectively — a closed union, not a dynamic column string, matching + * `InstanceApiKeyName`'s reasoning above. + */ +export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl'; + +/** + * {@link UsersRepository.getSynologyFields}'s return shape — structurally + * identical to `synology.service.ts`'s own `SynologyUserRecord` (every field + * optional, so an unrequested column is simply absent), deliberately + * defined here rather than imported from the service: a repository does not + * depend on a domain service's types, and the two stay structurally + * assignable without either side casting. + */ +export interface SynologyFieldsRow { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; + synology_did?: string | null; + synology_skip_ssl?: number | null; +} diff --git a/server/src/db/repositories/VacayCompanyHolidays.repository.ts b/server/src/db/repositories/VacayCompanyHolidays.repository.ts new file mode 100644 index 000000000..3b4f2e62f --- /dev/null +++ b/server/src/db/repositories/VacayCompanyHolidays.repository.ts @@ -0,0 +1,72 @@ +import type { VacayCompanyHolidays } from '../entities/VacayCompanyHolidays.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayCompanyHolidaysRepository extends TrekRepository { + /** + * VC29/VC67 — `SELECT date, fraction FROM vacay_company_holidays WHERE + * plan_id = ?` (`updatePlan`'s make-room-on-enable, narrower columns) and + * `SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ?` + * (`dissolvePlan`'s migration read, wider columns). One method returning all + * three columns — `updatePlan` destructures only `date`/`fraction` off the + * result, same rows either way. `fraction` is the half-holiday size (#2439). + */ + async listForPlan(planId: number): Promise<{ date: string; note: string | null; fraction: number }[]> { + const rows = await this.find({ plan: planId }, { fields: ['date', 'note', 'fraction'] }); + return rows.map((row) => ({ date: row.date, note: row.note ?? null, fraction: row.fraction })); + } + + /** VC93/VC112 — `SELECT date, fraction FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date` (`getSharedCalendars`) and `SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`getEntries`, unordered, full row). Two distinct statements — two methods. */ + async listDatesForRange(planId: number, start: string, end: string): Promise<{ date: string; fraction: number }[]> { + const rows = await this.find({ plan: planId, date: { $gte: start, $lt: end } }, { fields: ['date', 'fraction'], orderBy: { date: 'asc' } }); + return rows.map((row) => ({ date: row.date, fraction: row.fraction })); + } + + /** VC112 — `SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`getEntries`, full row, unordered). */ + async listForRange(planId: number, start: string, end: string): Promise<{ id: number; plan_id: number; date: string; note: string | null; fraction: number }[]> { + const rows = await this.find({ plan: planId, date: { $gte: start, $lt: end } }); + return rows.map((row) => ({ id: row.id, plan_id: row.plan_id, date: row.date, note: row.note ?? null, fraction: row.fraction })); + } + + /** VC24-sibling: VC25 — `DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear). */ + async deleteForPlanAndDate(planId: number, date: string): Promise { + await this.nativeDelete({ plan: planId, date }); + } + + /** VC104 — `DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`deleteYear`, the intersection-of-member-windows range). */ + async deleteForRange(planId: number, start: string, end: string): Promise { + await this.nativeDelete({ plan: planId, date: { $gte: start, $lt: end } }); + } + + /** VC70/VC73 — `INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)` (`dissolvePlan`'s two migration branches — identical statement). */ + async insertIgnore(planId: number, date: string, note: string, fraction: number): Promise { + await this.upsert({ plan: planId, date, note, fraction }, { onConflictFields: ['plan', 'date'], onConflictAction: 'ignore' }); + } + + /** + * VC118/VC132 — `SELECT id, fraction FROM vacay_company_holidays WHERE + * plan_id = ? AND date = ?` (`toggleCompanyHoliday`'s existing-row read) and + * `SELECT fraction FROM vacay_company_holidays WHERE plan_id = ? AND date = ?` + * (`toggleEntry`'s half-company-holiday read, #2439). Named + * `findByPlanAndDate`, not `find` — `EntityRepository#find` already exists + * with an incompatible signature. + */ + async findByPlanAndDate(planId: number, date: string): Promise<{ id: number; fraction: number } | null> { + const row = await this.findOne({ plan: planId, date }, { fields: ['id', 'fraction'] }); + return row ? { id: row.id, fraction: row.fraction } : null; + } + + /** VC133 — `UPDATE vacay_company_holidays SET fraction = ? WHERE id = ?` (`toggleCompanyHoliday`'s whole/half conversion, #2439). */ + async updateFraction(id: number, fraction: number): Promise { + await this.nativeUpdate({ id }, { fraction }); + } + + /** VC119 — `DELETE FROM vacay_company_holidays WHERE id = ?` (`toggleCompanyHoliday`'s clear-on-repeat-click). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** VC120 — `INSERT INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)` (`toggleCompanyHoliday`'s new row). */ + async insertHoliday(planId: number, date: string, note: string, fraction: number): Promise { + await this.insert({ plan: planId, date, note, fraction }); + } +} diff --git a/server/src/db/repositories/VacayEntries.repository.ts b/server/src/db/repositories/VacayEntries.repository.ts new file mode 100644 index 000000000..8476dcc0b --- /dev/null +++ b/server/src/db/repositories/VacayEntries.repository.ts @@ -0,0 +1,213 @@ +import { dateAdd } from '../dialect/sql-functions'; +import type { VacayEntries } from '../entities/VacayEntries.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +interface VacayEntriesKyselyDB { + vacay_entries: { id: number; plan_id: number; user_id: number; date: string; note: string | null; fraction: number; kind: string }; +} + +/** VC111's joined grid projection (`getEntries`) — the full entry row plus the author's username and color. */ +export interface VacayEntryWithPersonRow { + id: number; + plan_id: number; + user_id: number; + date: string; + note: string | null; + fraction: number; + kind: string; + person_name: string; + person_color: string; +} + +/** SQLite's `date(, '+N days')` — the same calendar-day arithmetic `dateAdd` spells in SQL (§13), computed in JS for {@link VacayEntriesRepository.shiftForOwnerWindow}'s collision pre-check. Verified against SQLite's own `date()` modifier: both walk the proleptic Gregorian calendar by whole days, so they agree on every input (the same reasoning R9's VC4 date-diff verification gives). */ +function shiftIsoDate(isoDate: string, days: number): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +export class VacayEntriesRepository extends TrekRepository { + /** + * VC1 — `SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction + * END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND + * date >= ? AND date < ?` (`usedDays`). Kysely's expression builder + * (`eb.case()`/`eb.fn.sum`/`eb.fn.coalesce`, the `BudgetItems.repository.ts + * #getPerPersonSummary` precedent) — the `ELSE fraction` branch is a + * COLUMN reference, which `sql-functions.ts#caseWhenEquals` cannot express + * (its `whenTrue`/`whenFalse` are bound VALUES, not column refs), so this + * is built directly, no `sql` tag. + */ + async sumFraction(userId: number, planId: number, start: string, end: string): Promise { + const row = await this.kysely() + .selectFrom('vacay_entries') + .select((eb) => eb.fn.coalesce(eb.fn.sum(eb.case().when('kind', '=', 'comp').then(0).else(eb.ref('fraction')).end()), eb.val(0)).as('used')) + .where('user_id', '=', userId) + .where('plan_id', '=', planId) + .where('date', '>=', start) + .where('date', '<', end) + .executeTakeFirst(); + // Plan 4 Task 8b: both `row?.` AND `?? 0` are unreachable here — an + // unqualified, ungrouped `COALESCE(SUM(...), 0)` always returns exactly + // one row with a non-null value (the `COALESCE` already resolves the + // "no matching rows" case to `0` at the SQL level). Kept for type-shape + // symmetry with `executeTakeFirst()`'s `| undefined` return type, same + // accepted class as `OauthClients:147`. + return row?.used ?? 0; + } + + /** VC2 — `SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'` (`compUsedDays`). */ + async sumCompFraction(userId: number, planId: number, start: string, end: string): Promise { + const row = await this.kysely() + .selectFrom('vacay_entries') + .select((eb) => eb.fn.coalesce(eb.fn.sum('fraction'), eb.val(0)).as('used')) + .where('user_id', '=', userId) + .where('plan_id', '=', planId) + .where('date', '>=', start) + .where('date', '<', end) + .where('kind', '=', 'comp') + .executeTakeFirst(); + // Plan 4 Task 8b: same unreachable `row?.`/`?? 0` shape as {@link + // sumFraction} above — `COALESCE(SUM(...), 0)` never returns a NULL + // value or a missing row for an unqualified, ungrouped aggregate. + return row?.used ?? 0; + } + + /** + * VC5 (`shiftOwnerEntriesForTripWindow`) — `UPDATE OR IGNORE vacay_entries + * SET date = date(date, ? || ' days') WHERE plan_id = ? AND user_id = ? AND + * date BETWEEN ? AND ?`. SQLite's per-statement `OR IGNORE` + * conflict-resolution clause applies to UPDATE as well as INSERT, but + * neither Kysely's `UpdateQueryBuilder` (only `.modifyEnd()`, which appends + * AFTER the whole statement, not after the `UPDATE` keyword) nor MikroORM's + * `nativeUpdate` exposes it — a genuine builder gap, not a helper this + * plan's R9 anticipated (R9's own VC5 note only covers the date + * EXPRESSION, via the existing `dateAdd`, not this conflict-resolution + * clause). Reproduced as a read-then-conditionally-write loop instead: + * read every candidate row, and for each one whose shifted date would + * collide with an existing `(user, plan, date)` row, skip it — `OR + * IGNORE`'s own per-row behavior, exactly — otherwise write it with + * `dateAdd` (still the SQL-computed value, per R9). Low-volume, + * request-scoped (a trip's own leave entries on a date-window move, never + * a hot path), so the per-row round trip is not a concern. + */ + async shiftForOwnerWindow(planId: number, userId: number, start: string, end: string, offset: number): Promise { + const platform = this.getEntityManager().getPlatform(); + // M1 (task-7-review.md): legacy's single `UPDATE OR IGNORE` walks rows in + // rowid order; without an explicit orderBy here, SQLite returns rows in + // (user, plan, date) index order instead, so the per-row collision check + // above skips different rows than legacy did on consecutive days inserted + // out of id order. + const rows = await this.find({ plan: planId, user: userId, date: { $gte: start, $lte: end } }, { fields: ['id', 'date'], orderBy: { id: 'asc' } }); + for (const row of rows) { + const shiftedDate = shiftIsoDate(row.date, offset); + const collision = await this.findOne({ plan: planId, user: userId, date: shiftedDate }, { fields: ['id'] }); + if (collision) continue; + await this.nativeUpdate({ id: row.id }, { date: dateAdd(platform, 'date', offset) }); + } + } + + /** VC24/VC30/VC121 — `DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear, and `makeRoomForCompanyHoliday`'s whole-holiday clear, reached from `updatePlan`'s company-holiday enable and `toggleCompanyHoliday`). */ + async deleteForPlanAndDate(planId: number, date: string): Promise { + await this.nativeDelete({ plan: planId, date }); + } + + /** VC134 — `UPDATE vacay_entries SET fraction = 0.5 WHERE plan_id = ? AND date = ? AND fraction > 0.5` (`makeRoomForCompanyHoliday`: a half company holiday halves a whole leave day, #2439). */ + async halveForPlanAndDate(planId: number, date: string): Promise { + await this.nativeUpdate({ plan: planId, date, fraction: { $gt: 0.5 } }, { fraction: 0.5 }); + } + + /** VC54/VC69/VC72 — `UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?` (`acceptInvite`'s migration-in, `dissolvePlan`'s two migration-out branches — three identical-text call sites). */ + async updatePlanIdForUser(newPlanId: number, oldPlanId: number, userId: number): Promise { + await this.nativeUpdate({ plan: oldPlanId, user: userId }, { plan: newPlanId }); + } + + /** VC101 — `SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?` (`deleteYear`'s author enumeration — reads authors off the entries themselves, catching orphans the member list would miss). */ + async listAuthorsForPlan(planId: number): Promise<{ user_id: number }[]> { + const rows = await this.kysely() + .selectFrom('vacay_entries') + .select('user_id') + .distinct() + .where('plan_id', '=', planId) + .execute(); + return rows.map((row) => ({ user_id: row.user_id })); + } + + /** + * VC102 (`deleteYear`) — `DELETE FROM vacay_entries WHERE plan_id = ? AND + * user_id = ? AND date >= ? AND date < ?`. The window is computed PER + * AUTHOR by the caller (each `resolveYearWindow(user_id, year)` call inside + * the per-author loop — #737-aware, since members can be on differently + * shaped leave years) and passed in already resolved; this method never + * re-derives it, so a shortcut that computed the window once and reused it + * across authors would be a caller-side bug, not something this method + * could introduce. + */ + async deleteForRange(planId: number, userId: number, start: string, end: string): Promise { + await this.nativeDelete({ plan: planId, user: userId, date: { $gte: start, $lt: end } }); + } + + /** + * VC111 (`getEntries`) — `SELECT e.*, u.username as person_name, + * COALESCE(c.color, '#6366f1') as person_color FROM vacay_entries e JOIN + * users u ON e.user_id = u.id LEFT JOIN vacay_user_colors c ON c.user_id = + * e.user_id AND c.plan_id = e.plan_id WHERE e.plan_id = ? AND e.date >= ? + * AND e.date < ?`. Kysely two-condition `LEFT JOIN` + * (`BudgetItems.repository.ts#getPerPersonSummary`'s + * `.onRef(...).onRef(...)` shape). + */ + async listForRangeWithPerson(planId: number, start: string, end: string): Promise { + interface EntriesWithPersonKyselyDB { + vacay_entries: { id: number; plan_id: number; user_id: number; date: string; note: string | null; fraction: number; kind: string }; + users: { id: number; username: string }; + vacay_user_colors: { id: number; user_id: number; plan_id: number; color: string | null }; + } + return this.kysely() + .selectFrom('vacay_entries as e') + .innerJoin('users as u', 'u.id', 'e.user_id') + .leftJoin('vacay_user_colors as c', (join) => join.onRef('c.user_id', '=', 'e.user_id').onRef('c.plan_id', '=', 'e.plan_id')) + .select((eb) => [ + 'e.id', 'e.plan_id', 'e.user_id', 'e.date', 'e.note', 'e.fraction', 'e.kind', + 'u.username as person_name', + eb.fn.coalesce('c.color', eb.val('#6366f1')).as('person_color'), + ]) + .where('e.plan_id', '=', planId) + .where('e.date', '>=', start) + .where('e.date', '<', end) + .execute(); + } + + /** VC92 (`getSharedCalendars`) — `SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date`, over the OWNER's plan and the VIEWER's leave-year window. */ + async listForOwnerRange(planId: number, userId: number, start: string, end: string): Promise<{ date: string; fraction: number; kind: string | null }[]> { + const rows = await this.find( + { plan: planId, user: userId, date: { $gte: start, $lt: end } }, + { fields: ['date', 'fraction', 'kind'], orderBy: { date: 'asc' } }, + ); + return rows.map((row) => ({ date: row.date, fraction: row.fraction, kind: row.kind ?? null })); + } + + /** + * VC114 — `SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ? + * AND date = ? AND plan_id = ?` (`toggleEntry`'s existing-entry read). + * Named `findByUserDatePlan`, not `find` — `EntityRepository#find` already + * exists with an incompatible signature. + */ + async findByUserDatePlan(userId: number, date: string, planId: number): Promise<{ id: number; fraction: number; kind: string | null } | null> { + const row = await this.findOne({ user: userId, date, plan: planId }, { fields: ['id', 'fraction', 'kind'] }); + return row ? { id: row.id, fraction: row.fraction, kind: row.kind ?? null } : null; + } + + /** VC115 — `DELETE FROM vacay_entries WHERE id = ?` (`toggleEntry`'s clear-on-repeat-click). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** VC116 — `UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?` (`toggleEntry`'s in-place conversion). */ + async updateFractionKind(id: number, fraction: number, kind: string): Promise { + await this.nativeUpdate({ id }, { fraction, kind }); + } + + /** VC117 — `INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)` (`toggleEntry`'s new entry — `note` is always `''` from this path). */ + async insertEntry(planId: number, userId: number, date: string, note: string, fraction: number, kind: string): Promise { + await this.insert({ plan: planId, user: userId, date, note, fraction, kind }); + } +} diff --git a/server/src/db/repositories/VacayHolidayCalendars.repository.ts b/server/src/db/repositories/VacayHolidayCalendars.repository.ts new file mode 100644 index 000000000..3ffe6d2e5 --- /dev/null +++ b/server/src/db/repositories/VacayHolidayCalendars.repository.ts @@ -0,0 +1,127 @@ +import type { VacayHolidayCalendars } from '../entities/VacayHolidayCalendars.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `vacay_holiday_calendars` row as the legacy `SELECT *` returned it (Plan 3f Task 5). */ +export interface VacayHolidayCalendarRow { + id: number; + plan_id: number; + type: 'public_holiday' | 'school_holiday'; + region: string; + label: string | null; + color: string; + sort_order: number; +} + +const _vacayHolidayCalendarRowKeys: AssertRowKeys = true; + +/** `updateHolidayCalendar` (VC41)'s conditionally-included-fields patch — §15.9's JS-assembled SET list, mapped onto `nativeUpdate`'s own `undefined`-means-omit semantics, same shape as `VacayPlansRepository.update`. */ +export interface VacayHolidayCalendarPatch { + region?: string; + type?: 'public_holiday' | 'school_holiday'; + label?: string | null; + color?: string; + sort_order?: number; +} + +/** + * Plan 3f Task 2 (`SchoolHolidaysService.deleteRegion`) appended + * {@link VacayHolidayCalendarsRepository.existsForSchoolRegion} first — kept + * untouched, exactly as landed; Task 5 (vacay) appends the rest of this + * repository around it below, per the plan's own file-ownership rule for + * this shared file (`.superpowers/sdd/2026-09-25-orm-phase3f/task-2-brief.md` + * / `task-5-brief.md`). + */ +export class VacayHolidayCalendarsRepository extends TrekRepository { + /** + * SH14 (`deleteRegion`'s cross-domain integrity guard, the reverse + * direction of vacay's own VC45) — `SELECT id FROM vacay_holiday_calendars + * WHERE type = 'school_holiday' AND region = ? LIMIT 1`. `region` is a + * plain `.text()` column with no FK/relation to `SchoolHolidayRegions` at + * all (§14/§15.9 of the plan's inventory, verified against both entities + * directly, not assumed) — it stores the synthesized `-MANUAL- + * ` code as a free-form string, matched by VALUE, so this stays a + * plain `WHERE` filter on two scalar columns, never a joined relation. + */ + async existsForSchoolRegion(region: string): Promise { + const row = await this.findOne({ type: 'school_holiday', region }, { fields: ['id'] }); + return row !== null; + } + + // --------------------------------------------------------------------- + // Plan 3f Task 5 (`VacayService`) — additive, appended after Task 2's own + // method above (untouched). + // --------------------------------------------------------------------- + + /** VC22 — `SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id` (`applyHolidayCalendars`). */ + async listPublicForPlan(planId: number): Promise { + const rows = await this.find({ plan: planId, type: 'public_holiday' }, { orderBy: { sort_order: 'asc', id: 'asc' } }); + return rows.map((row) => toRow(row) as VacayHolidayCalendarRow); + } + + /** + * VC37/VC131 — `SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? + * ORDER BY sort_order, id` (`updatePlan`'s re-select, `getPlanData` — two + * identical-text call sites, one method). + */ + async listForPlan(planId: number): Promise { + const rows = await this.find({ plan: planId }, { orderBy: { sort_order: 'asc', id: 'asc' } }); + return rows.map((row) => toRow(row) as VacayHolidayCalendarRow); + } + + /** VC26 — `SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?` (`migrateHolidayCalendars`'s one-time legacy-region migration guard — existence only, any row). */ + async existsForPlan(planId: number): Promise { + const row = await this.findOne({ plan: planId }, { fields: ['id'] }); + return row !== null; + } + + /** + * VC27/VC38 — `INSERT INTO vacay_holiday_calendars (plan_id, region, label, + * color, sort_order) VALUES (?, ?, NULL, ?, 0)` (`migrateHolidayCalendars`, + * `type` defaulted by the schema) and `INSERT INTO vacay_holiday_calendars + * (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, + * ?)` (`addHolidayCalendar`, `type` explicit). One flexible method — the + * legacy migration insert's implicit schema-default `type` and the + * explicit insert's bound `type` write the identical column value when the + * caller passes `'public_holiday'` for the former. Named `insertCalendar`, + * not `insert` — `EntityRepository#insert` already exists with an + * incompatible signature. + */ + async insertCalendar(planId: number, type: 'public_holiday' | 'school_holiday', region: string, label: string | null, color: string, sortOrder: number): Promise { + return this.insert({ plan: planId, type, region, label, color, sort_order: sortOrder }); + } + + /** + * VC39/VC42 — `SELECT * FROM vacay_holiday_calendars WHERE id = ?` + * (`addHolidayCalendar`'s post-insert re-select, `updateHolidayCalendar`'s + * post-write re-select — identical statement, two call sites). Named + * `findById`, not `find` — `EntityRepository#find` already exists with an + * incompatible signature. + */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as VacayHolidayCalendarRow) : null; + } + + /** + * VC40/VC43 — `SELECT * FROM vacay_holiday_calendars WHERE id = ? AND + * plan_id = ?` — the plan-scoping guard (`updateHolidayCalendar`'s own + * read, `deleteHolidayCalendar`'s own read — identical statement, two call + * sites). + */ + async findScopedForPlan(id: number, planId: number): Promise { + const row = await this.findOne({ id, plan: planId }); + return row ? (toRow(row) as VacayHolidayCalendarRow) : null; + } + + /** VC41 — `UPDATE vacay_holiday_calendars SET ${dynamic} WHERE id = ?`, §15.9's conditionally-included-fields shape (up to 5 optional columns). */ + async update(id: number, patch: VacayHolidayCalendarPatch): Promise { + if (Object.keys(patch).length === 0) return; + await this.nativeUpdate({ id }, { ...patch }); + } + + /** VC44 — `DELETE FROM vacay_holiday_calendars WHERE id = ?` (`deleteHolidayCalendar`, called only after {@link findScopedForPlan} found a scoped row). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/VacayPlanMembers.repository.ts b/server/src/db/repositories/VacayPlanMembers.repository.ts new file mode 100644 index 000000000..c85fbdb37 --- /dev/null +++ b/server/src/db/repositories/VacayPlanMembers.repository.ts @@ -0,0 +1,190 @@ +import type { VacayPlanMembers } from '../entities/VacayPlanMembers.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `vacay_plan_members` row as the legacy `SELECT *` returned it (VC51). */ +export interface VacayPlanMemberRow { + id: number; + plan_id: number; + user_id: number; + status: string | null; + created_at: string | null; +} + +const _vacayPlanMemberRowKeys: AssertRowKeys = true; + +/** VC17/VC129/VC130's joined member+user projections. */ +export interface VacayMemberUserRow { + id: number; + username: string; + email: string; +} + +/** VC129 — outgoing pending invites (`getPlanData`). */ +export interface VacayPendingOutgoingRow { + id: number; + user_id: number; + username: string; + email: string; + created_at: string | null; +} + +/** VC130 — incoming pending invites (`getPlanData`). */ +export interface VacayPendingIncomingRow { + id: number; + plan_id: number; + username: string; + email: string; + created_at: string | null; +} + +export class VacayPlanMembersRepository extends TrekRepository { + /** + * VC13/VC76 — `SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND + * status = 'accepted'` (`getActivePlan`, `peekActivePlan`). No `fields` + * narrowing: `plan_id` is the `persist(false)` shadow of the `plan` + * relation — narrowing to JUST a `persist(false)` mirror column silently + * drops it from the result (verified directly). + */ + async findAcceptedPlanId(userId: number): Promise<{ plan_id: number } | null> { + const row = await this.findOne({ user: userId, status: 'accepted' }); + return row ? { plan_id: row.plan_id } : null; + } + + /** VC17 — `SELECT u.id, u.username, u.email FROM vacay_plan_members m JOIN users u ON m.user_id = u.id WHERE m.plan_id = ? AND m.status = 'accepted'` (`getPlanUsers`'s accepted-member half). */ + async listAcceptedWithUsers(planId: number): Promise { + return this.qb('m') + .join('m.user', 'u') + .select(['u.id', 'u.username', 'u.email']) + .where({ 'm.plan': planId, 'm.status': 'accepted' }) + .execute('all', false); + } + + /** + * VC19/VC68 — `SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND + * status = 'accepted'` (`notifyPlanUsers`, `dissolvePlan`'s owner branch). + * No `fields` narrowing — same `persist(false)`-mirror trap as + * {@link findAcceptedPlanId}. + */ + async listAcceptedUserIds(planId: number): Promise<{ user_id: number }[]> { + const rows = await this.find({ plan: planId, status: 'accepted' }); + return rows.map((row) => ({ user_id: row.user_id })); + } + + /** VC48 — `SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?` (`sendInvite`'s dedup guard — any status). */ + async findMembership(planId: number, userId: number): Promise<{ id: number; status: string | null } | null> { + const row = await this.findOne({ plan: planId, user: userId }, { fields: ['id', 'status'] }); + return row ? { id: row.id, status: row.status ?? null } : null; + } + + /** VC49 — `SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'` (`sendInvite`'s single-fusion guard). */ + async findAcceptedForUser(userId: number): Promise<{ id: number } | null> { + const row = await this.findOne({ user: userId, status: 'accepted' }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** VC50 — `INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')`. */ + async insertPending(planId: number, userId: number): Promise { + await this.insert({ plan: planId, user: userId, status: 'pending' }); + } + + /** + * VC47 (`sendInvite`'s invite-target lookup) — `SELECT id, username FROM + * users WHERE id = ? AND COALESCE(is_guest, 0) = 0` — a guest is never + * invitable (#1362-class). `UsersRepository` is a concurrently in-flight + * file owned by another Plan 3f task at the time of this write and outside + * this task's Named file set (same reasoning as `VacayPlansRepository + * .findVacayUser`'s docstring) — a direct `users`-table Kysely read here + * instead. + */ + async findInvitableUser(id: number): Promise<{ id: number; username: string } | null> { + interface UsersKyselyDB { + users: { id: number; username: string; is_guest: number | null }; + } + const row = await this.kysely() + .selectFrom('users') + .select(['id', 'username']) + .where('id', '=', id) + .where((eb) => eb(eb.fn.coalesce('is_guest', eb.val(0)), '=', 0)) + .executeTakeFirst(); + return row ? { id: row.id, username: row.username } : null; + } + + /** VC51 — `SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'` (`acceptInvite`'s own pending-invite read). */ + async findPending(planId: number, userId: number): Promise { + const row = await this.findOne({ plan: planId, user: userId, status: 'pending' }); + return row ? (toRow(row) as VacayPlanMemberRow) : null; + } + + /** VC52 — `UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?`. */ + async accept(id: number): Promise { + await this.nativeUpdate({ id }, { status: 'accepted' }); + } + + /** VC65/VC66 — `DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'` (`declineInvite`'s own withdrawal, `cancelInvite`'s owner-initiated withdrawal — identical statement, `targetUserId` in place of `userId`). */ + async deletePending(planId: number, userId: number): Promise { + await this.nativeDelete({ plan: planId, user: userId, status: 'pending' }); + } + + /** VC71 — `DELETE FROM vacay_plan_members WHERE plan_id = ?` (`dissolvePlan`'s owner branch). */ + async deleteForPlan(planId: number): Promise { + await this.nativeDelete({ plan: planId }); + } + + /** VC74 — `DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?` (`dissolvePlan`'s member branch). */ + async deleteForPlanAndUser(planId: number, userId: number): Promise { + await this.nativeDelete({ plan: planId, user: userId }); + } + + /** VC129 — `SELECT m.id, m.user_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN users u ON m.user_id = u.id WHERE m.plan_id = ? AND m.status = 'pending'` (`getPlanData`'s outgoing invites). */ + async listPendingForPlan(planId: number): Promise { + return this.qb('m') + .join('m.user', 'u') + .select(['m.id', 'u.id as user_id', 'u.username', 'u.email', 'm.created_at']) + .where({ 'm.plan': planId, 'm.status': 'pending' }) + .execute('all', false); + } + + /** VC130 — `SELECT m.id, m.plan_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id JOIN users u ON p.owner_id = u.id WHERE m.user_id = ? AND m.status = 'pending'` (`getPlanData`'s incoming invites — the OWNER's username/email, not the member's). */ + async listPendingForUser(userId: number): Promise { + return this.qb('m') + .join('m.plan', 'p') + .join('p.owner', 'u') + .select(['m.id', 'p.id as plan_id', 'u.username', 'u.email', 'm.created_at']) + .where({ 'm.user': userId, 'm.status': 'pending' }) + .execute('all', false); + } + + /** + * VC75 (`getAvailableUsers`, the fusion-candidate picker) — `SELECT u.id, + * u.username, u.email FROM users u WHERE u.id != ? AND COALESCE(u.is_guest, + * 0) = 0 AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE + * plan_id = ?) AND u.id NOT IN (SELECT user_id FROM vacay_plan_members + * WHERE status = 'accepted') AND u.id NOT IN (SELECT owner_id FROM + * vacay_plans WHERE id IN (SELECT plan_id FROM vacay_plan_members WHERE + * status = 'accepted')) ORDER BY u.username`. Three nested `NOT IN` + * subqueries (§13) — Kysely, no `sql` tag, every operand builder-expressed. + */ + async listAvailableForFusion(userId: number, planId: number): Promise { + interface FusionCandidateKyselyDB { + users: { id: number; username: string; email: string; is_guest: number | null }; + vacay_plan_members: { id: number; plan_id: number; user_id: number; status: string | null }; + vacay_plans: { id: number; owner_id: number }; + } + return this.kysely() + .selectFrom('users as u') + .select(['u.id', 'u.username', 'u.email']) + .where('u.id', '!=', userId) + .where((eb) => eb(eb.fn.coalesce('u.is_guest', eb.val(0)), '=', 0)) + .where('u.id', 'not in', (eb) => eb.selectFrom('vacay_plan_members').select('user_id').where('plan_id', '=', planId)) + .where('u.id', 'not in', (eb) => eb.selectFrom('vacay_plan_members').select('user_id').where('status', '=', 'accepted')) + .where('u.id', 'not in', (eb) => + eb + .selectFrom('vacay_plans') + .select('owner_id') + .where('id', 'in', (eb2) => eb2.selectFrom('vacay_plan_members').select('plan_id').where('status', '=', 'accepted')), + ) + .orderBy('u.username', 'asc') + .execute(); + } +} diff --git a/server/src/db/repositories/VacayPlans.repository.ts b/server/src/db/repositories/VacayPlans.repository.ts new file mode 100644 index 000000000..7471c0a99 --- /dev/null +++ b/server/src/db/repositories/VacayPlans.repository.ts @@ -0,0 +1,123 @@ +import type { VacayPlans } from '../entities/VacayPlans.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `vacay_plans` row as the legacy `SELECT *` returned it (Plan 3f Task 5, + * inventory §2, VC7-131's `vacay_plans` group). `owner_id` is the + * `persist(false)` shadow of the `owner` relation (§14's FK-mirror TRAP — + * every plain `find`/`findOne` below reads the whole row, so it always + * hydrates); `created_at` is the one column `VacayService`'s own hand-written + * `VacayPlan` interface never declared (nothing reads it), kept here anyway + * so {@link AssertRowKeys} still pins every scalar column of the entity. + */ +export interface VacayPlanRow { + id: number; + owner_id: number; + block_weekends: number | null; + holidays_enabled: number | null; + holidays_region: string | null; + school_holidays_enabled: number | null; + company_holidays_enabled: number | null; + carry_over_enabled: number | null; + created_at: string | null; + weekend_days: string | null; + week_start: number; +} + +const _vacayPlanRowKeys: AssertRowKeys = true; + +/** `updatePlan` (VC28)'s conditionally-included-fields patch — §15.9: a JS-assembled SET list, not the presence-sentinel idiom, maps directly onto `nativeUpdate`'s own `undefined`-means-omit semantics. */ +export interface VacayPlanPatch { + block_weekends?: number; + holidays_enabled?: number; + holidays_region?: string | null; + school_holidays_enabled?: number; + company_holidays_enabled?: number; + carry_over_enabled?: number; + weekend_days?: string; + week_start?: number; +} + +export class VacayPlansRepository extends TrekRepository { + /** VC7/VC9/VC78 — `SELECT * FROM vacay_plans WHERE owner_id = ?` (one method, three identical-text call sites: `getOwnPlan`'s pre-insert read, its post-insert re-select, and `peekActivePlan`'s owner fallback). */ + async findByOwner(ownerId: number): Promise { + const plan = await this.findOne({ owner: ownerId }); + return plan ? (toRow(plan) as VacayPlanRow) : null; + } + + /** + * VC14 — `SELECT * FROM vacay_plans WHERE id = ?`, the hottest statement in + * this file: seven identical-text call sites (`getActivePlan`, + * `getPlanUsers`, `peekActivePlan`, `updatePlan`'s pre- and post-write + * reads, `addYear`, `deleteYear`, `toggleEntry`, `getStats` — VC14/15/31/ + * 36/77/97/107/113/122), all ONE method. + */ + async findById(id: number): Promise { + const plan = await this.findOne({ id }); + return plan ? (toRow(plan) as VacayPlanRow) : null; + } + + /** + * VC18/VC103 — `SELECT owner_id FROM vacay_plans WHERE id = ?` + * (`notifyPlanUsers`, `deleteYear`'s carry-over recompute). No `fields` + * narrowing: `owner_id` is the `persist(false)` shadow of the `owner` + * relation, and narrowing a `find`/`findOne` to JUST a `persist(false)` + * mirror column silently drops it from the result (verified directly, the + * same trap `PlaceRatingsRepository.listForPlaces`'s docstring names for + * the join-alias case) — a full read costs one extra row's worth of + * columns, never a wrong answer. + */ + async findOwnerId(id: number): Promise<{ owner_id: number } | null> { + const row = await this.findOne({ id }); + return row ? { owner_id: row.owner_id } : null; + } + + /** VC53 — `SELECT id FROM vacay_plans WHERE owner_id = ?` (`acceptInvite`'s own-plan-to-migrate-from lookup) — a NARROWER column set than {@link findByOwner}'s VC7/9/78 (no `username`/`email`/etc., just `id`), a genuinely distinct statement. */ + async findIdByOwner(ownerId: number): Promise<{ id: number } | null> { + const row = await this.findOne({ owner: ownerId }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** VC21 — `SELECT holidays_enabled FROM vacay_plans WHERE id = ?` (`applyHolidayCalendars`'s feature gate). */ + async getHolidaysEnabled(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['holidays_enabled'] }); + return row?.holidays_enabled ?? null; + } + + /** + * VC8 — `INSERT INTO vacay_plans (owner_id) VALUES (?)` (`getOwnPlan`'s + * lazy-create; every other column takes the schema's own default). Named + * `insertForOwner`, not `insert` — `EntityRepository#insert` already + * exists with an incompatible signature (`Users.repository.ts::insertUser`'s + * precedent). + */ + async insertForOwner(ownerId: number): Promise { + return this.insert({ owner: ownerId }); + } + + /** VC28 — `UPDATE vacay_plans SET ${dynamic column list} WHERE id = ?`, §15.9's conditionally-included-fields shape: only the keys present in `patch` are written, matching the legacy JS-assembled SET list exactly (an absent key is truly untouched, never a presence-sentinel `CASE WHEN`). */ + async update(id: number, patch: VacayPlanPatch): Promise { + if (Object.keys(patch).length === 0) return; + await this.nativeUpdate({ id }, { ...patch }); + } + + /** + * VC16 (`getPlanUsers`'s owner lookup) — `SELECT id, username, email FROM + * users WHERE id = ?`. `UsersRepository` is a concurrently in-flight file + * owned by another Plan 3f task at the time of this write (Task 4's own + * additive block) — this task's Named file set does not include it, so + * this read stays here (a same-shape precedent to `VacayPlanMembersRepository + * .listAvailableForFusion`'s direct `users`-table Kysely read: a + * repository is not required to route every cross-table read through the + * table's "owning" repository, only to own its OWN table's writes) rather + * than risk a conflicting concurrent edit to a file this task does not own. + */ + async findVacayUser(id: number): Promise<{ id: number; username: string; email: string } | null> { + interface UsersKyselyDB { + users: { id: number; username: string; email: string }; + } + const row = await this.kysely().selectFrom('users').select(['id', 'username', 'email']).where('id', '=', id).executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/VacayShares.repository.ts b/server/src/db/repositories/VacayShares.repository.ts new file mode 100644 index 000000000..77fa1d5b2 --- /dev/null +++ b/server/src/db/repositories/VacayShares.repository.ts @@ -0,0 +1,180 @@ +import type { VacayShares } from '../entities/VacayShares.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `vacay_shares` row as the legacy `SELECT *` returned it. */ +export interface VacayShareRow { + id: number; + owner_id: number; + user_id: number; + hidden: number; + created_at: string | null; +} + +const _vacayShareRowKeys: AssertRowKeys = true; + +/** VC81/VC82/VC91's joined share+user projections. */ +export interface VacayOutgoingShareRow { + id: number; + user_id: number; + username: string; +} + +export interface VacayIncomingShareRow { + id: number; + owner_id: number; + hidden: number; + username: string; +} + +export class VacaySharesRepository extends TrekRepository { + /** VC84 — `SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?` (`shareCalendar`'s dedup guard). */ + async findByOwnerAndUser(ownerId: number, userId: number): Promise<{ id: number } | null> { + const row = await this.findOne({ owner: ownerId, user: userId }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** + * VC83 (`shareCalendar`'s share-target lookup) — `SELECT id FROM users + * WHERE id = ? AND COALESCE(is_guest, 0) = 0` — a NARROWER column set than + * `VacayPlanMembersRepository.findInvitableUser`'s VC47 (no `username`), a + * genuinely distinct statement (inventory §2's own note), not a reuse. + * `UsersRepository` is a concurrently in-flight file owned by another + * Plan 3f task at the time of this write and outside this task's Named + * file set (same reasoning as `VacayPlansRepository.findVacayUser`'s + * docstring) — a direct `users`-table Kysely read here instead. + */ + async existsInvitableUser(id: number): Promise { + interface UsersKyselyDB { + users: { id: number; is_guest: number | null }; + } + const row = await this.kysely() + .selectFrom('users') + .select('id') + .where('id', '=', id) + .where((eb) => eb(eb.fn.coalesce('is_guest', eb.val(0)), '=', 0)) + .executeTakeFirst(); + return row !== undefined; + } + + /** VC81 — `SELECT s.id, s.user_id, u.username FROM vacay_shares s JOIN users u ON s.user_id = u.id WHERE s.owner_id = ? ORDER BY s.id` (`listShares`'s outgoing half — usernames only, emails withheld). */ + async listOutgoing(ownerId: number): Promise { + return this.qb('s') + .join('s.user', 'u') + .select(['s.id', 'u.id as user_id', 'u.username']) + .where({ 's.owner': ownerId }) + .orderBy({ 's.id': 'asc' }) + .execute('all', false); + } + + /** VC82/VC91 — `SELECT s.id, s.owner_id, s.hidden, u.username FROM vacay_shares s JOIN users u ON s.owner_id = u.id WHERE s.user_id = ? ORDER BY s.id` (`listShares`'s incoming half, `getSharedCalendars`'s share list — identical statement, two call sites). */ + async listIncoming(userId: number): Promise { + return this.qb('s') + .join('s.owner', 'u') + .select(['s.id', 'u.id as owner_id', 's.hidden', 'u.username']) + .where({ 's.user': userId }) + .orderBy({ 's.id': 'asc' }) + .execute('all', false); + } + + /** + * VC85 — `INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)` + * (`shareCalendar`). Named `insertShare`, not `insert` — + * `EntityRepository#insert` already exists with an incompatible signature. + */ + async insertShare(ownerId: number, userId: number): Promise { + await this.insert({ owner: ownerId, user: userId }); + } + + /** + * R6 — `findScopedForRemoval(id, userId)`: `WHERE id = ? AND (owner_id = ? + * OR user_id = ?)`, mirroring `removeShare`'s wider ownership-or-viewer + * check (VC86, today an UNSCOPED `SELECT * FROM vacay_shares WHERE id = ?` + * followed by a JS check — `share.owner_id !== userId && share.user_id !== + * userId` — that runs before any mutation either way). This method scopes + * the SAME check IN the SQL statement itself: a share row belonging to + * neither the owner NOR the viewer never comes back, so the caller (which + * then deletes-by-id via {@link deleteById}) cannot act on a foreign row + * even if a future change to the service dropped the JS check. Behaviorally + * identical to today on every input (the JS check ran first either way); + * flagged per R6/the plan's "For the user" note as a genuine SQL-shape + * tightening, not silent parity. + */ + async findScopedForRemoval(id: number, userId: number): Promise { + const row = await this.findOne({ id, $or: [{ owner: userId }, { user: userId }] }); + return row ? (toRow(row) as VacayShareRow) : null; + } + + /** + * R6 — `findScopedForHide(id, userId)`: `WHERE id = ? AND user_id = ?`, + * mirroring `setShareHidden`'s NARROWER viewer-only check (VC88, today the + * same unscoped `SELECT * FROM vacay_shares WHERE id = ?` plus a JS check — + * `share.user_id !== userId` only, the OWNER may not hide their own + * outgoing share). Deliberately a DIFFERENT predicate from + * {@link findScopedForRemoval}, not the same method with a mode flag — an + * owner's own row must NOT match this one even though it would match + * `findScopedForRemoval`. + */ + async findScopedForHide(id: number, userId: number): Promise { + const row = await this.findOne({ id, user: userId }); + return row ? (toRow(row) as VacayShareRow) : null; + } + + /** VC87 — `DELETE FROM vacay_shares WHERE id = ?` (`removeShare`, called only after {@link findScopedForRemoval} found a scoped row). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** VC89 — `UPDATE vacay_shares SET hidden = ? WHERE id = ?` (`setShareHidden`, called only after {@link findScopedForHide} found a scoped row). */ + async setHidden(id: number, hidden: boolean): Promise { + await this.nativeUpdate({ id }, { hidden: hidden ? 1 : 0 }); + } + + /** VC20 — `SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${dynamic})` (`notifyShareViewers`'s fan-out list). Empty `ownerIds` short-circuits before any query, matching the caller's own guard. */ + async listDistinctViewerIdsForOwners(ownerIds: number[]): Promise { + if (ownerIds.length === 0) return []; + interface VacaySharesKyselyDB { + vacay_shares: { id: number; owner_id: number; user_id: number; hidden: number; created_at: string | null }; + } + const rows = await this.kysely() + .selectFrom('vacay_shares') + .select('user_id') + .distinct() + .where('owner_id', 'in', ownerIds) + .execute(); + return rows.map((row) => row.user_id); + } + + /** + * VC90 (`getShareAvailableUsers`, the share-candidate picker) — `SELECT + * u.id, u.username FROM users u WHERE u.id != ? AND COALESCE(u.is_guest, 0) + * = 0 AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?) + * AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id = ? UNION + * SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = + * 'accepted') ORDER BY u.username`. A `UNION` nested inside a `NOT IN` + * subquery (§13) — Kysely, no `sql` tag. + */ + async listAvailableForShare(userId: number, planId: number): Promise<{ id: number; username: string }[]> { + interface ShareCandidateKyselyDB { + users: { id: number; username: string; is_guest: number | null }; + vacay_shares: { id: number; owner_id: number; user_id: number }; + vacay_plans: { id: number; owner_id: number }; + vacay_plan_members: { id: number; plan_id: number; user_id: number; status: string | null }; + } + return this.kysely() + .selectFrom('users as u') + .select(['u.id', 'u.username']) + .where('u.id', '!=', userId) + .where((eb) => eb(eb.fn.coalesce('u.is_guest', eb.val(0)), '=', 0)) + .where('u.id', 'not in', (eb) => eb.selectFrom('vacay_shares').select('user_id').where('owner_id', '=', userId)) + .where('u.id', 'not in', (eb) => + eb + .selectFrom('vacay_plans') + .select('owner_id') + .where('id', '=', planId) + .union(eb.selectFrom('vacay_plan_members').select('user_id as owner_id').where('plan_id', '=', planId).where('status', '=', 'accepted')), + ) + .orderBy('u.username', 'asc') + .execute(); + } +} diff --git a/server/src/db/repositories/VacayUserColors.repository.ts b/server/src/db/repositories/VacayUserColors.repository.ts new file mode 100644 index 000000000..4f97d1c00 --- /dev/null +++ b/server/src/db/repositories/VacayUserColors.repository.ts @@ -0,0 +1,52 @@ +import type { VacayUserColors } from '../entities/VacayUserColors.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayUserColorsRepository extends TrekRepository { + /** VC79 — `SELECT color FROM vacay_user_colors WHERE plan_id = ?` (`viewerColors`). */ + async listForPlan(planId: number): Promise<{ color: string | null }[]> { + const rows = await this.find({ plan: planId }, { fields: ['color'] }); + return rows.map((row) => ({ color: row.color ?? null })); + } + + /** + * VC57/VC60/VC80/VC124/VC128 — `SELECT color FROM vacay_user_colors WHERE + * user_id = ? AND plan_id = ?` (five identical-text call sites: + * `acceptInvite`'s own-color read and its post-remap re-read, + * `shareDisplayColor`, `getStats`, `getPlanData`'s per-member map). Named + * `findColor`, not `find` — `EntityRepository#find` already exists with an + * incompatible signature. + */ + async findColor(userId: number, planId: number): Promise<{ color: string | null } | null> { + const row = await this.findOne({ user: userId, plan: planId }, { fields: ['color'] }); + return row ? { color: row.color ?? null } : null; + } + + /** VC59 — `SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?` (`acceptInvite`'s taken-colors collection). */ + async listOtherColors(planId: number, excludeUserId: number): Promise<{ color: string | null }[]> { + const rows = await this.find({ plan: planId, user: { $ne: excludeUserId } }, { fields: ['color'] }); + return rows.map((row) => ({ color: row.color ?? null })); + } + + /** + * VC12/VC58/VC62 — `INSERT OR IGNORE INTO vacay_user_colors (user_id, + * plan_id, color) VALUES (?, ?, ?)` (`getOwnPlan`'s lazy default color, + * `acceptInvite`'s own-color migration and its no-collision else-branch — + * three identical-text call sites, one method). + */ + async insertIgnore(userId: number, planId: number, color: string | null): Promise { + await this.upsert({ user: userId, plan: planId, color }, { onConflictFields: ['user', 'plan'], onConflictAction: 'ignore' }); + } + + /** + * VC46/VC61 — `INSERT INTO vacay_user_colors (user_id, plan_id, color) + * VALUES (?, ?, ?) ON CONFLICT(user_id, plan_id) DO UPDATE SET color = + * excluded.color` (`setUserColor`'s own write, `acceptInvite`'s + * collision-remap write — identical statement, two call sites). + */ + async upsertColor(userId: number, planId: number, color: string): Promise { + await this.upsert( + { user: userId, plan: planId, color }, + { onConflictFields: ['user', 'plan'], onConflictAction: 'merge', onConflictMergeFields: ['color'] }, + ); + } +} diff --git a/server/src/db/repositories/VacayUserSettings.repository.ts b/server/src/db/repositories/VacayUserSettings.repository.ts new file mode 100644 index 000000000..a2d880baa --- /dev/null +++ b/server/src/db/repositories/VacayUserSettings.repository.ts @@ -0,0 +1,48 @@ +import type { VacayUserSettings } from '../entities/VacayUserSettings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `vacay_user_settings` row as the legacy `SELECT *` returned it — `user` is the entity's primary key (`[PrimaryKeyProp]?: 'user'`), so `user_id` is its `persist(false)` shadow scalar. */ +export interface VacayUserSettingsRow { + user_id: number; + year_type: string; + year_start_month: number; + year_start_day: number; + hire_date: string | null; +} + +const _vacayUserSettingsRowKeys: AssertRowKeys = true; + +export class VacayUserSettingsRepository extends TrekRepository { + /** + * VC3 — `SELECT * FROM vacay_user_settings WHERE user_id = ?` + * (`getUserYearSettings`). Named `findForUser`, not `find` — + * `EntityRepository#find` already exists with an incompatible signature. + */ + async findForUser(userId: number): Promise { + const row = await this.findOne({ user: userId }); + return row ? (toRow(row) as VacayUserSettingsRow) : null; + } + + /** + * VC6 — `INSERT INTO vacay_user_settings (user_id, year_type, + * year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?) ON + * CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, + * year_start_month = excluded.year_start_month, year_start_day = + * excluded.year_start_day, hire_date = excluded.hire_date` + * (`updateYearSettings`). `user` is the entity's own primary key, so + * `onConflictFields: ['user']` targets it directly — the default `'merge'` + * action already updates every OTHER column present in the payload (all + * four here, matching the legacy statement's own exhaustive `SET` list), + * so no `onConflictMergeFields` narrowing is needed (unlike + * `VacayUserYearsRepository.upsertCarriedOver`, which deliberately narrows + * to ONE column). Named `upsertSettings`, not `upsert` — + * `EntityRepository#upsert` already exists with an incompatible signature. + */ + async upsertSettings(userId: number, yearType: string, yearStartMonth: number, yearStartDay: number, hireDate: string | null): Promise { + await this.upsert( + { user: userId, year_type: yearType, year_start_month: yearStartMonth, year_start_day: yearStartDay, hire_date: hireDate }, + { onConflictFields: ['user'], onConflictAction: 'merge' }, + ); + } +} diff --git a/server/src/db/repositories/VacayUserYears.repository.ts b/server/src/db/repositories/VacayUserYears.repository.ts new file mode 100644 index 000000000..590f260e1 --- /dev/null +++ b/server/src/db/repositories/VacayUserYears.repository.ts @@ -0,0 +1,104 @@ +import type { VacayUserYears } from '../entities/VacayUserYears.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `vacay_user_years` row as the legacy `SELECT *` returned it. */ +export interface VacayUserYearRow { + id: number; + user_id: number; + plan_id: number; + year: number; + vacation_days: number | null; + carried_over: number | null; +} + +const _vacayUserYearRowKeys: AssertRowKeys = true; + +export class VacayUserYearsRepository extends TrekRepository { + /** + * VC34/VC98/VC109/VC123 — `SELECT * FROM vacay_user_years WHERE user_id = ? + * AND plan_id = ? AND year = ?` (four identical-text call sites: + * `updatePlan`'s carry-over chain, `addYear`, `deleteYear`, `getStats`). + * Named `findForYear`, not `find` — `EntityRepository#find` already exists + * with an incompatible signature. + */ + async findForYear(userId: number, planId: number, year: number): Promise { + const row = await this.findOne({ user: userId, plan: planId, year }); + return row ? (toRow(row) as VacayUserYearRow) : null; + } + + /** VC55 — `SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?` (`acceptInvite`'s own-plan-to-date migration read, no `year` filter). */ + async listForUserAndPlan(userId: number, planId: number): Promise { + const rows = await this.find({ user: userId, plan: planId }); + return rows.map((row) => toRow(row) as VacayUserYearRow); + } + + /** + * VC11/VC56/VC64/VC99 — `INSERT OR IGNORE INTO vacay_user_years (user_id, + * plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)`, one + * method for all four call sites (`getOwnPlan`'s lazy seed, `acceptInvite`'s + * carried-over migration, its all-plan-years seed, `addYear`'s per-user + * seed) — two sites bind `vacationDays`/`carriedOver` as literal `30`/`0` + * at the CALL SITE, one binds the actual prior values, one binds a + * JS-computed carry-over; the VALUES an `INSERT OR IGNORE` writes are + * identical whichever way the caller spells them (a literal `30` in the + * legacy SQL text inserts the exact same row a bound `30` does), so a + * single flexible method, not four near-duplicates. + */ + async insertIgnore(userId: number, planId: number, year: number, vacationDays: number | null, carriedOver: number | null): Promise { + await this.upsert( + { user: userId, plan: planId, year, vacation_days: vacationDays, carried_over: carriedOver }, + { onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'ignore' }, + ); + } + + /** + * VC35/VC126 — `INSERT INTO vacay_user_years (user_id, plan_id, year, + * vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) ON CONFLICT(user_id, + * plan_id, year) DO UPDATE SET carried_over = ?` (`updatePlan`'s + * carry-over recompute chain, `getStats`'s per-request write — + * byte-identical statement, two call sites). `onConflictMergeFields: + * ['carried_over']` is load-bearing (`PlaceRatingsRepository.upsertRating`'s + * precedent): without it `em.upsert`'s default merge set would also touch + * `vacation_days` on a conflict, which the legacy `DO UPDATE SET + * carried_over = ?` never does — `vacation_days: 30` in the payload below + * is only ever written on the INSERT branch, exactly as the legacy + * statement's own literal `30` is. + */ + async upsertCarriedOver(userId: number, planId: number, year: number, carriedOver: number): Promise { + await this.upsert( + { user: userId, plan: planId, year, vacation_days: 30, carried_over: carriedOver }, + { onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'merge', onConflictMergeFields: ['carried_over'] }, + ); + } + + /** + * VC127 — `INSERT INTO vacay_user_years (user_id, plan_id, year, + * vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) ON CONFLICT(user_id, + * plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days` + * (`updateStats`) — a distinct conflict ACTION from {@link upsertCarriedOver} + * (merges `vacation_days`, leaves `carried_over` untouched on conflict; the + * literal `carried_over: 0` below is INSERT-branch-only, same reasoning). + */ + async upsertVacationDays(userId: number, planId: number, year: number, vacationDays: number): Promise { + await this.upsert( + { user: userId, plan: planId, year, vacation_days: vacationDays, carried_over: 0 }, + { onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'merge', onConflictMergeFields: ['vacation_days'] }, + ); + } + + /** VC32 — `UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?` (`updatePlan`'s carry-over-disabled reset). */ + async resetCarriedOverForPlan(planId: number): Promise { + await this.nativeUpdate({ plan: planId }, { carried_over: 0 }); + } + + /** VC105 — `DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?` (`deleteYear`). */ + async deleteForYear(planId: number, year: number): Promise { + await this.nativeDelete({ plan: planId, year }); + } + + /** VC110 — `UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?` (`deleteYear`'s year+1 carry-over recompute). */ + async updateCarriedOver(userId: number, planId: number, year: number, carriedOver: number): Promise { + await this.nativeUpdate({ user: userId, plan: planId, year }, { carried_over: carriedOver }); + } +} diff --git a/server/src/db/repositories/VacayYears.repository.ts b/server/src/db/repositories/VacayYears.repository.ts new file mode 100644 index 000000000..035420b85 --- /dev/null +++ b/server/src/db/repositories/VacayYears.repository.ts @@ -0,0 +1,55 @@ +import type { VacayYears } from '../entities/VacayYears.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayYearsRepository extends TrekRepository { + /** + * VC23/VC33/VC63/VC94 — `SELECT year FROM vacay_years WHERE plan_id = ?` + * (VC23/VC63, unordered — `applyHolidayCalendars`, `acceptInvite`'s year + * seeding, both iterate the set without caring about order) and `... ORDER + * BY year` (VC33/VC94 — `updatePlan`'s carry-over chain, which walks + * consecutive years and DOES need ascending order, and `listYears`, whose + * caller renders them in order). One method, always ordered: adding + * `ORDER BY year` to the two order-independent call sites changes nothing + * about the ROW SET either reads (same rows, just enumerated consistently) + * — flagged here as a non-behavioral consolidation, not a data-shape + * change, so four near-duplicate statements don't become four near- + * duplicate methods. + */ + async listForPlan(planId: number): Promise { + const rows = await this.find({ plan: planId }, { fields: ['year'], orderBy: { year: 'asc' } }); + return rows.map((row) => row.year); + } + + /** VC95/VC106 — `SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?` (`addYear`'s duplicate-year guard, `deleteYear`'s "does year+1 still exist" check). */ + async exists(planId: number, year: number): Promise { + const row = await this.findOne({ plan: planId, year }, { fields: ['id'] }); + return row !== null; + } + + /** VC10 — `INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)` (`getOwnPlan`'s lazy year-seed). */ + async insertIgnore(planId: number, year: number): Promise { + await this.upsert({ plan: planId, year }, { onConflictFields: ['plan', 'year'], onConflictAction: 'ignore' }); + } + + /** + * VC96 — `INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)` (`addYear`'s + * plain insert, already guarded by {@link exists} outside the transaction — + * no `OR IGNORE` needed here, unlike {@link insertIgnore} above). Named + * `insertYear`, not `insert` — `EntityRepository#insert` already exists + * with an incompatible signature. + */ + async insertYear(planId: number, year: number): Promise { + await this.insert({ plan: planId, year }); + } + + /** VC108 — `SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1` (`deleteYear`'s new-previous-year lookup). */ + async previousYear(planId: number, beforeYear: number): Promise { + const row = await this.findOne({ plan: planId, year: { $lt: beforeYear } }, { fields: ['year'], orderBy: { year: 'desc' } }); + return row?.year ?? null; + } + + /** VC100 — `DELETE FROM vacay_years WHERE plan_id = ? AND year = ?`. */ + async deleteForPlanAndYear(planId: number, year: number): Promise { + await this.nativeDelete({ plan: planId, year }); + } +} diff --git a/server/src/db/repositories/VisitedCountries.repository.ts b/server/src/db/repositories/VisitedCountries.repository.ts new file mode 100644 index 000000000..2f2b35f9b --- /dev/null +++ b/server/src/db/repositories/VisitedCountries.repository.ts @@ -0,0 +1,123 @@ +import type { VisitedCountries } from '../entities/VisitedCountries.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `visited_countries` — every scalar column, `AT10`'s own read shape. */ +export interface VisitedCountryRow { + country_code: string; + created_at: string | null; + source: string; +} + +/** + * Two separate table shapes for the SAME `visited_countries` table, not one + * shared interface: {@link listForUser} SELECTs `created_at`, while + * {@link markVisited}/{@link markFromRegion} INSERT it never — the legacy + * statements' own column lists omit it, leaving the schema's `DEFAULT + * CURRENT_TIMESTAMP` to fill it in. Kysely validates `.values()` against + * exactly the columns its `DB` type parameter declares (it has no notion of + * "this column exists in the real table but is optional here" short of the + * `Generated<>` wrapper, which — unlike every other column type in this + * program — did not resolve `created_at` out of `InsertObject`'s required + * set when tried here); declaring a NARROWER write-only shape that never + * mentions `created_at` at all reproduces the legacy statement's own column + * list exactly, with no wrapper needed. + */ +interface VisitedCountriesReadKyselyDB { + visited_countries: { user_id: number; country_code: string; source: string; created_at: string | null }; +} +interface VisitedCountriesWriteKyselyDB { + visited_countries: { user_id: number; country_code: string; source: string }; +} + +/** + * `visited_countries` — the user's own explicit "I've been here" marks + * (Plan 3f Task 1, atlas). `markVisited` (AT12) needs the `changes > 0` + * signal the legacy `.run(...).changes` gave (whether this call actually + * ADDED the mark, not just re-confirmed one already there, so a caller + * reporting "N countries added" doesn't double-count) — Kysely's + * `InsertResult.numInsertedOrUpdatedRows` is the SQLite `changes` count + * verbatim (`BudgetCategoryOrderRepository`'s own `insertIgnore`-vs-this + * distinction: that one doesn't need the signal, this one does), so this one + * write goes through Kysely rather than `em.upsert` (which has no comparable + * "was this actually new" return). + */ +export class VisitedCountriesRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + /** AT10 (`listVisitedCountries`) — `SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('visited_countries') + .select(['country_code', 'created_at', 'source']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .execute(); + } + + /** + * AT5/AT7/AT43 (`stats`'s no-trip early return, `stats`'s manual-country + * merge, `getTravelStats`'s manual-country merge — three identical-text + * call sites) — `SELECT country_code FROM visited_countries WHERE user_id + * = ?`. Returns the bare code list; every one of the three callers only + * ever reads `.country_code` off the legacy row shape. + */ + async listCodesForUser(userId: number): Promise { + const rows = await this.writeDb().selectFrom('visited_countries').select('country_code').where('user_id', '=', userId).execute(); + return rows.map((r) => r.country_code); + } + + /** AT8/AT9 (`countryPlaces`'s no-trip early return, `countryPlaces`'s mark lookup — identical text) — `SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?`. */ + async findSource(userId: number, countryCode: string): Promise<{ source: string } | undefined> { + return await this.writeDb() + .selectFrom('visited_countries') + .select('source') + .where('user_id', '=', userId) + .where('country_code', '=', countryCode) + .executeTakeFirst(); + } + + /** + * AT12 (`markCountry`, inside its transaction) — `INSERT OR IGNORE INTO + * visited_countries (user_id, country_code, source) VALUES (?, ?, ?)`. + * Returns whether this call actually inserted a new row (see class + * docstring) — the legacy `.changes > 0` this repository preserves. + */ + async markVisited(userId: number, countryCode: string, source: 'manual' | 'dawarich'): Promise { + const result = await this.writeDb() + .insertInto('visited_countries') + .values({ user_id: userId, country_code: countryCode, source }) + .onConflict((oc) => oc.doNothing()) + .executeTakeFirst(); + return (result.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** AT14 (`unmarkCountry`, inside its transaction) — `DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?`. */ + async unmark(userId: number, countryCode: string): Promise { + await this.writeDb().deleteFrom('visited_countries').where('user_id', '=', userId).where('country_code', '=', countryCode).execute(); + } + + /** + * AT19 (`markRegion`, inside its transaction) — `INSERT OR IGNORE INTO + * visited_countries (user_id, country_code) VALUES (?, ?)`, a DISTINCT + * 2-column variant of {@link markVisited}'s 3-column insert. The legacy + * statement's column list omits `source`, letting the schema's own + * `DEFAULT 'manual'` fill it — reproduced here by passing that literal + * explicitly (`VisitedCountries.entity.ts`'s `source: p.text() + * .default('manual')`, the exact same value the omitted column would + * have taken). No `changes` signal here; the legacy statement never + * checked it either. + */ + async markFromRegion(userId: number, countryCode: string): Promise { + await this.writeDb() + .insertInto('visited_countries') + .values({ user_id: userId, country_code: countryCode, source: 'manual' }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/VisitedRegions.repository.ts b/server/src/db/repositories/VisitedRegions.repository.ts new file mode 100644 index 000000000..3163b6b0e --- /dev/null +++ b/server/src/db/repositories/VisitedRegions.repository.ts @@ -0,0 +1,94 @@ +import type { VisitedRegions } from '../entities/VisitedRegions.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `visited_regions` — the columns `AT21`'s read needs. */ +export interface VisitedRegionRow { + region_code: string; + region_name: string; + country_code: string; +} + +/** + * Two separate table shapes for the SAME `visited_regions` table (the + * `VisitedCountriesRepository`'s own docstring explains why: {@link + * listForUser} SELECTs `created_at`, `markVisited` INSERTs it never — the + * legacy statement's own column list omits it, leaving the schema's + * `DEFAULT CURRENT_TIMESTAMP` to fill it). + */ +interface VisitedRegionsReadKyselyDB { + visited_regions: { user_id: number; region_code: string; region_name: string; country_code: string; created_at: string | null }; +} +interface VisitedRegionsWriteKyselyDB { + visited_regions: { user_id: number; region_code: string; region_name: string; country_code: string }; +} + +/** + * `visited_regions` — the user's own explicit region-level marks, plus the + * regions `place_regions` derives (Plan 3f Task 1, atlas). `unmarkAllInCountry` + * (AT15, filters on `country_code`) and `unmark` (AT26, filters on + * `region_code`) are DIFFERENT statements over the same table — one deletes + * every region in a country (`unmarkCountry`'s cascade), the other deletes + * one specific region (`unmarkRegion`'s own delete) — never collapsed into + * one method with a flag, matching the plan's own "two genuinely different + * scoped checks stay two methods" ruling (R6) for this table's shape too. + */ +export class VisitedRegionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + /** AT21 (`listManuallyVisitedRegions`) — `SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('visited_regions') + .select(['region_code', 'region_name', 'country_code']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .execute(); + } + + /** AT24 (`hasVisibleRegionForCountry`) — `SELECT region_code FROM visited_regions WHERE user_id = ? AND country_code = ?`. */ + async listRegionCodesForCountry(userId: number, countryCode: string): Promise { + const rows = await this.writeDb() + .selectFrom('visited_regions') + .select('region_code') + .where('user_id', '=', userId) + .where('country_code', '=', countryCode) + .execute(); + return rows.map((r) => r.region_code); + } + + /** AT25 (`unmarkRegion`, inside its transaction) — `SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?`. */ + async findCountryCode(userId: number, regionCode: string): Promise { + const row = await this.writeDb() + .selectFrom('visited_regions') + .select('country_code') + .where('user_id', '=', userId) + .where('region_code', '=', regionCode) + .executeTakeFirst(); + return row?.country_code; + } + + /** AT17 (`markRegion`, inside its transaction) — `INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)`. */ + async markVisited(userId: number, regionCode: string, regionName: string, countryCode: string): Promise { + await this.writeDb() + .insertInto('visited_regions') + .values({ user_id: userId, region_code: regionCode, region_name: regionName, country_code: countryCode }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** AT15 (`unmarkCountry`, inside its transaction) — `DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?` (every region in the country). */ + async unmarkAllInCountry(userId: number, countryCode: string): Promise { + await this.writeDb().deleteFrom('visited_regions').where('user_id', '=', userId).where('country_code', '=', countryCode).execute(); + } + + /** AT26 (`unmarkRegion`, inside its transaction) — `DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?` (one region). */ + async unmark(userId: number, regionCode: string): Promise { + await this.writeDb().deleteFrom('visited_regions').where('user_id', '=', userId).where('region_code', '=', regionCode).execute(); + } +} diff --git a/server/src/db/repositories/WebauthnChallenges.repository.ts b/server/src/db/repositories/WebauthnChallenges.repository.ts new file mode 100644 index 000000000..ce52f860b --- /dev/null +++ b/server/src/db/repositories/WebauthnChallenges.repository.ts @@ -0,0 +1,103 @@ +import type { WebauthnChallenges } from '../entities/WebauthnChallenges.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `PasskeyService.storeChallenge` (PK2). */ +export interface NewChallengeRow { + challenge: string; + user_id: number | null; + type: 'registration' | 'authentication'; + expires_at: number; +} + +/** + * The `webauthn_challenges` table shape as `claimChallenge`'s + * `em.getKysely()` call needs it — an explicit type argument rather than + * relying on `getKysely()`'s automatic entity-metadata inference (kept + * narrow to the one column this repository ever reads back). + */ +interface WebauthnChallengesKyselyDB { + webauthn_challenges: { + challenge: string; + user_id: number | null; + type: string; + expires_at: number; + }; +} + +/** The single-use, TTL'd WebAuthn ceremony challenge store — PK1–PK3. */ +export class WebauthnChallengesRepository extends TrekRepository { + /** + * `DELETE FROM webauthn_challenges WHERE expires_at < ?` — best-effort + * sweep run before minting a new challenge (no cron for this table; see + * the inventory's surprise #9). + */ + async purgeExpired(now: number): Promise { + await this.nativeDelete({ expires_at: { $lt: now } }); + } + + /** + * `INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) + * VALUES (?, ?, ?, ?)`. `EntityRepository.insert()`, not `create()` + + * `persist().flush()` — a native insert with no identity-map/UnitOfWork + * side effects (see `WebauthnCredentialsRepository.insertCredential`'s + * docstring for why `persist().flush()` is the hazard here: it flushes + * the entire request's UnitOfWork, which can write back a stale + * identity-mapped entity from an unrelated earlier read in the same + * request). + */ + async insertChallenge(row: NewChallengeRow): Promise { + await this.insert({ + challenge: row.challenge, + user: row.user_id, + type: row.type, + expires_at: row.expires_at, + }); + } + + /** + * `DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND + * expires_at > ? RETURNING user_id` — the anti-replay primitive: it runs + * BEFORE any async verification, so a concurrent double-submit of the + * same assertion can spend the row exactly once. + * + * Built on `em.getKysely()` (D4's T6 tier), not the QueryBuilder's own + * `.returning()` (the InviteTokensRepository.incrementUsedCount shape): + * verified empirically against the installed MikroORM 7.2.1 that the + * QueryBuilder's `processReturningStatement` (`@mikro-orm/sql`'s + * `QueryBuilder.js`) only ever honours an explicit `.returning()` hint + * for `INSERT`/`UPDATE` — a bare `DELETE` has no `data` in its internal + * state, and the very first branch of that method (`if (!data && + * !this.#state.insertSubQuery) return;`) exits before the "always + * respect explicit returning hint" branch below it is ever reached. A + * `delete().returning([...]).execute('run')` therefore silently drops + * the RETURNING clause: it deletes the row (`affectedRows: 1`) but + * `result.row`/`result.rows` come back empty every time — this is NOT + * the `update().returning()` shape Task 0 proved; it's a different, + * narrower gap the docs don't call out. `em.getKysely()` is bound to the + * ambient transaction the same way the QueryBuilder is (see its own + * doc comment — inside `em.transactional`/`uow.transactional` it reuses + * the open transaction's connection), so D6's single-connection- + * serialises-writers guarantee still holds: no SELECT→await→DELETE + * window for either caller to lose, proven by `WEBAUTHN-CHAL-REPO`'s + * concurrent-claim test asserting exactly one non-null winner. + * + * `.returning(['user_id'])` only (not `.selectAll()`/`returningAll()`), + * matching the legacy statement's own narrower `RETURNING user_id`. + * + * `this.kysely()` (`_shared/trek-repository.ts`, Plan 3b interlude B), not + * `this.getEntityManager().getKysely()` directly: `em.getKysely()` + * resolves its `EntityManager` with `getContext(false)`, the same + * validation gap the base class closes for every other repository path — + * the base's `kysely()` helper validates first, then delegates. + */ + async claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): Promise<{ user_id: number | null } | null> { + const row = await this.kysely() + .deleteFrom('webauthn_challenges') + .where('challenge', '=', challenge) + .where('type', '=', type) + .where('expires_at', '>', now) + .returning(['user_id']) + .executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/WebauthnCredentials.repository.ts b/server/src/db/repositories/WebauthnCredentials.repository.ts new file mode 100644 index 000000000..290144130 --- /dev/null +++ b/server/src/db/repositories/WebauthnCredentials.repository.ts @@ -0,0 +1,262 @@ +import type { WebauthnCredentials } from '../entities/WebauthnCredentials.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A full `webauthn_credentials` row (`SELECT * ...`), as PK9 (`passkeyLoginVerify`) + * needs it — `public_key` stays a `Buffer` end to end: `BlobType` (the entity's + * `p.blob()` column type) has no `toJSON` override, so `EntityTransformer`'s + * default (return the value unchanged) applies and `toRow`'s `wrap().toObject()` + * never touches the bytes. + */ +export interface WebauthnCredentialRow { + id: number; + user_id: number; + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; + created_at: string | null; + last_used_at: string | null; +} + +const _webauthnCredentialRowKeys: AssertRowKeys = true; + +/** `PasskeyService.passkeyRegisterVerify` (PK7) — only verifier-vouched fields. */ +export interface NewCredentialRow { + user_id: number; + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; +} + +/** PK5's `excludeCredentials` shape and PK8/PK13's shared management-panel shape. */ +export interface CredentialTransportsRow { + credential_id: string; + transports: string | null; +} + +/** The column set PK8 (post-registration re-select) and PK13 (`listPasskeys`) both project. */ +export interface PasskeyPanelRow { + id: number; + name: string | null; + device_type: string | null; + backed_up: number; + created_at: string | null; + last_used_at: string | null; +} + +const PANEL_FIELDS = ['id', 'name', 'device_type', 'backed_up', 'created_at', 'last_used_at'] as const; + +function toPanelRow(row: { id: number; name?: string | null; device_type?: string | null; backed_up: number; created_at?: string | null; last_used_at?: string | null }): PasskeyPanelRow { + return { + id: row.id, + name: row.name ?? null, + device_type: row.device_type ?? null, + backed_up: row.backed_up, + created_at: row.created_at ?? null, + last_used_at: row.last_used_at ?? null, + }; +} + +/** + * Plan 3b Task 1 reserved this class and implemented `hasAny` (MP3/AU24 — + * shared with `mfa-policy.guard.ts` and `AuthService.updateAppSettings`). + * Task 3 (`PasskeyService`) adds everything else: the full WebAuthn + * credential-store surface (PK5–PK18, minus PK6's dup check which the + * service still gates the same insert with, race-proofed by the surrounding + * `uow.transactional`). `hasAny` is untouched — nothing here changes its + * name or shape. + */ +export class WebauthnCredentialsRepository extends TrekRepository { + /** + * `SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1` (MP3/AU24 + * — identical statement, two callers). `findOne` with a narrow `fields` + * selection mirrors the legacy existence probe without pulling a full + * row; MikroORM's `count()` has no built-in short-circuit, so this is the + * closer match to "does one exist" than `count() > 0`, which would still + * scan/aggregate every matching row. + * + * `disableIdentityMap: true` (every row-out read in this file, applied by + * the base class's default since Plan 3b interlude B — + * `_shared/trek-repository.ts`): this + * method's caller (`mfa-policy.guard.ts`, `AuthService.updateAppSettings`) + * can run in the same request as other `webauthn_credentials`/`users` + * reads and writes; a `findOne` that joins the identity map (even for a + * `fields: ['id']` existence probe) registers a managed entity whose + * unselected columns hold whatever the query returned as undefined/stale + * — if that entity is later merged with a broader projection AND a + * sibling `nativeUpdate` changes a column out of band in the same + * transaction, the closing `flush()` can write the identity-mapped + * entity's stale in-memory snapshot back over the `nativeUpdate`'s write + * (reproduced live: `PUT /api/auth/me/settings` returning 200 while + * discarding the change). `disableIdentityMap: true` returns an isolated, + * unmanaged entity instead — nothing this method reads is ever merged + * into the request's identity map, so it can never be flushed. + */ + async hasAny(userId: number): Promise { + const row = await this.findOne({ user: userId }, { fields: ['id'] }); + return row !== null; + } + + // --------------------------------------------------------------------- + // PK5 — passkeyRegisterOptions' excludeCredentials + // --------------------------------------------------------------------- + + /** `SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?` */ + async listExcludeCredentials(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: ['credential_id', 'transports'] }); + return rows.map((row) => ({ credential_id: row.credential_id, transports: row.transports ?? null })); + } + + // --------------------------------------------------------------------- + // PK6 — passkeyRegisterVerify's in-transaction duplicate check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM webauthn_credentials WHERE credential_id = ?` — called + * from inside `uow.transactional` alongside `insertCredential` so the + * check-then-insert can never race a UNIQUE violation past the service's + * `DUPLICATE_CREDENTIAL` sentinel (identity-compared, not string-matched). + * `disableIdentityMap: true` per `hasAny`'s docstring above — this read + * runs inside the same transaction as PK7's write, so keeping it out of + * the identity map matters even more here. + */ + async existsByCredentialId(credentialId: string): Promise { + const row = await this.findOne({ credential_id: credentialId }, { fields: ['id'] }); + return row !== null; + } + + // --------------------------------------------------------------------- + // PK7 — credential enrollment (same transaction as PK6) + // --------------------------------------------------------------------- + + /** + * `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, + * counter, transports, device_type, backed_up, name, aaguid, last_used_at) + * VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)` — `last_used_at` is always + * `NULL` on insert (a fresh credential has never been used yet), same as + * the legacy statement's literal. + * + * `EntityRepository.insert()`, not `create()` + `persist().flush()`: a + * native insert that commits immediately and has no identity-map/UnitOfWork + * side effects, so it can never be part of a stale-snapshot flush the way + * a persisted-and-tracked entity could; `persist().flush()` would also + * flush the ENTIRE request's UnitOfWork, not just this row, which is + * exactly the write-discarding hazard `hasAny`'s docstring describes. + */ + async insertCredential(row: NewCredentialRow): Promise { + await this.insert({ + user: row.user_id, + credential_id: row.credential_id, + public_key: row.public_key, + counter: row.counter, + transports: row.transports, + device_type: row.device_type, + backed_up: row.backed_up, + name: row.name, + aaguid: row.aaguid, + last_used_at: null, + }); + } + + // --------------------------------------------------------------------- + // PK8 — post-registration re-select (after the transaction resolves) + // --------------------------------------------------------------------- + + /** + * `SELECT id, name, device_type, backed_up, created_at, last_used_at + * FROM webauthn_credentials WHERE credential_id = ?` — a DIFFERENT + * column set from PK9's full-row read below, so a separate method (D4: + * mirror each statement's own projection, never one full-row read the + * caller trims down). + */ + async findCreatedCredential(credentialId: string): Promise { + const row = await this.findOne({ credential_id: credentialId }, { fields: [...PANEL_FIELDS] }); + return row ? toPanelRow(row) : null; + } + + // --------------------------------------------------------------------- + // PK9 — login-verify ceremony input (full row, Buffer public_key) + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM webauthn_credentials WHERE credential_id = ?`. + * `disableIdentityMap: true`, not `{ refresh: true }` — this is not a + * primary-key filter (`credential_id` is a unique column, not `id`), and + * per `hasAny`'s docstring, a plain `refresh: true` re-snapshot risks a + * stale-write-back on flush if this same entity is already (or later) + * identity-mapped under a different projection within the same request — + * PK11's `updateCounterAndLastUsed` runs a `nativeUpdate` on this exact + * row moments later, in the same request. + */ + async findByCredentialId(credentialId: string): Promise { + const credential = await this.findOne({ credential_id: credentialId }); + return credential ? (toRow(credential) as WebauthnCredentialRow) : null; + } + + // --------------------------------------------------------------------- + // PK11 — counter/last-used bookkeeping (same transaction as UsersRepository.touchLastLogin) + // --------------------------------------------------------------------- + + /** `UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async updateCounterAndLastUsed(id: number, counter: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { counter, last_used_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // PK13 — settings-panel list + // --------------------------------------------------------------------- + + /** + * `SELECT id, name, device_type, backed_up, created_at, last_used_at + * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC` + */ + async listForPanel(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: [...PANEL_FIELDS], orderBy: { created_at: 'desc' } }); + return rows.map((row) => toPanelRow(row)); + } + + // --------------------------------------------------------------------- + // PK14 — rename (ownership in the WHERE) + // --------------------------------------------------------------------- + + /** + * `UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?` + * — ownership enforced in the WHERE, not a separate check; returns the + * affected-row count so `renamePasskey` keeps its 404-never-403 behaviour + * (a foreign or unknown id both yield 0, indistinguishable by design). + */ + async renameOwned(id: number, userId: number, name: string): Promise { + return this.nativeUpdate({ id, user: userId }, { name }); + } + + // --------------------------------------------------------------------- + // PK16 — delete (ownership in the WHERE) + // --------------------------------------------------------------------- + + /** `DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?` */ + async deleteOwned(id: number, userId: number): Promise { + return this.nativeDelete({ id, user: userId }); + } + + // --------------------------------------------------------------------- + // PK18 — admin bulk-clear + // --------------------------------------------------------------------- + + /** `DELETE FROM webauthn_credentials WHERE user_id = ?` — returns the deleted count for `adminResetPasskeys`' response. */ + async deleteAllForUser(userId: number): Promise { + return this.nativeDelete({ user: userId }); + } +} diff --git a/server/src/db/repositories/_shared/collection-role.ts b/server/src/db/repositories/_shared/collection-role.ts new file mode 100644 index 000000000..b065a6c7b --- /dev/null +++ b/server/src/db/repositories/_shared/collection-role.ts @@ -0,0 +1,87 @@ +import type { CollectionMembers } from '../../entities/CollectionMembers.entity'; +import type { Collections } from '../../entities/Collections.entity'; +import type { TrekRepository } from './trek-repository'; + +/** + * Plan 3h Task 1, R6 — the collections cluster's own membership/role model, + * as ONE shared typed predicate instead of three ad hoc re-implementations. + * + * Legacy shape (`collections.service.ts` at HEAD, pre-conversion): + * - `isVisible` (CL2) — `SELECT 1 FROM collections WHERE id=? AND + * owner_id=? UNION SELECT 1 FROM collection_members WHERE + * collection_id=? AND user_id=? AND status='accepted' LIMIT 1`. + * - `isOwner` (CL3) — `SELECT 1 FROM collections WHERE id=? AND owner_id=?`. + * - `roleOf` (CL4) — calls `isOwner` first (`'owner'` short-circuit), else + * `SELECT role FROM collection_members WHERE collection_id=? AND + * user_id=? AND status='accepted'`, normalizing an unrecognized/legacy + * role value to `'editor'` (only `'admin'`/`'viewer'` pass through as-is). + * + * `roleOf`'s own legacy body ALREADY resolved `isVisible`/`isOwner` as its + * own first branch — the three functions were never independent, only + * independently re-queried. {@link resolveCollectionRole} is the SAME + * two-step resolution (owner match, else an accepted member row's role), + * built once here: `isVisible` becomes `role !== null`, `isOwner` becomes + * `role === 'owner'`, `roleOf` returns the role directly. + * `assertAccess`/`assertCanEdit`/`assertCanDelete` stay service-level + * orchestration (CollectionsService) — they call this predicate and branch + * on their own three distinct refusal shapes (404 no-access, 403-viewer + * edit-refused, 403-non-admin delete-refused); this file has no knowledge + * of HTTP status codes. + * + * **QB-callable form** ({@link resolveCollectionRole}): takes the caller's + * own `CollectionsRepository`/`CollectionMembersRepository` (both extend + * `TrekRepository`, typed generically here so this file stays independent + * of either repository class — the same reason `_shared/owned-lookup.ts` + * types its helpers against `TrekRepository` rather than a concrete + * repository) and resolves the role via two ordinary `findOne` calls — + * MikroORM's own QueryBuilder-backed API, the "repository methods that need + * it inline" form R6 asks for. This is what `CollectionsService`'s + * `isVisible`/`isOwner`/`roleOf` call directly; Task 2's own + * `assertCanEdit`/`assertCanDelete` call sites throughout the saved-places/ + * copy-to-trip/labels/invites surface go through the SAME service methods, + * never re-deriving the predicate. + * + * A Kysely-callable boolean-`EXISTS`/`OR` twin of this predicate (for a + * caller already building a raw, multi-join statement through + * `this.kysely()`, modelled on `packing-visibility.ts`'s + * `packingVisibleToActorExpr`) was drafted alongside this QB form but had no + * consumer by Plan 3h's whole-plan review — Task 2's own dynamic OR-condition + * methods (`matchingCollectionPlaces`/`findMembership`, CL58/CL69) never + * needed it — and was deleted there (L5) rather than shipped untested. Add it + * back, with a consumer or its own parity test, if a future task needs it. + * + * Proven against five actor cases (owner, admin member, editor member, + * viewer member, an invited-but-still-pending user, a complete stranger) by + * `tests/unit/db/repositories/_shared/collection-role.parity.test.ts`, + * mutation-proved (flip one branch, confirm the matching case goes red) — + * Task 2 imports that harness rather than re-deriving it. + */ +export type CollectionRole = 'owner' | 'admin' | 'editor' | 'viewer' | null; + +/** + * The two-step role resolution CL2/CL3/CL4 all reduce to: an owner match + * short-circuits to `'owner'`; otherwise an accepted member row's own + * `role` column is read and normalized (`'admin'`/`'viewer'` pass through, + * anything else — including a future/unknown value — becomes `'editor'`, + * the legacy `roleOf`'s own fallback); no row of either kind resolves to + * `null` (no access). + * + * `fields: ['id']`/`fields: ['role']` — a narrow projection, like every + * other "rows out, existence/value only" read in this program + * (`findOwnedByUser`'s own docstring). + */ +export async function resolveCollectionRole( + collections: TrekRepository, + members: TrekRepository, + collectionId: number, + userId: number, +): Promise { + const owner = await collections.findOne({ id: collectionId, owner: userId }, { fields: ['id'] }); + if (owner) return 'owner'; + const member = await members.findOne( + { collection: collectionId, user: userId, status: 'accepted' }, + { fields: ['role'] }, + ); + if (!member) return null; + return member.role === 'admin' || member.role === 'viewer' ? (member.role as 'admin' | 'viewer') : 'editor'; +} diff --git a/server/src/db/repositories/_shared/owned-lookup.ts b/server/src/db/repositories/_shared/owned-lookup.ts new file mode 100644 index 000000000..bee215f0a --- /dev/null +++ b/server/src/db/repositories/_shared/owned-lookup.ts @@ -0,0 +1,103 @@ +import type { FilterQuery, FilterValue, OrderDefinition } from '@mikro-orm/core'; +import type { TrekRepository } from './trek-repository'; + +/** + * Shared lookup shapes for entities scoped to a user. + * + * Built for `TagsRepository` (Plan 3a Task 3): `listByUser`/`findByIdAndUser` + * are the same two query shapes every strictly-owned entity needs + * (`tags.user_id` is `NOT NULL`) — list-scoped-to-owner and find-one-by-id + * with an ownership check. `CategoriesRepository` uses neither: its legacy + * `list`/`getById` (`SELECT * FROM categories ORDER BY name ASC` / `SELECT * + * FROM categories WHERE id = ?`) have no owner filter at all, despite + * `categories.user_id` being nullable — changing that would be a behaviour + * change, not a refactor, so it stays plain `find`/`findOne`. + * + * A third helper, `findOwnedOrGlobal` (id match OR a NULL owner column — + * the shape `categories.getById` would need if it were ever scoped), was + * built alongside these two and then deleted: Task 0 re-review flagged that + * no categories or tags site actually filters "owner or global", so it had + * only test coverage and no real caller (R6 — "dead code is worse than a + * missing helper"). If a future entity needs that shape, recover it from + * git history rather than reintroducing it speculatively. + * + * Typed `repo: TrekRepository` (Task 7 review, A-L3/B-L4 — was the core + * `EntityRepository` until this task, the last two places in the + * repository tree still restating `disableIdentityMap: true` per call + * because the type couldn't see `TrekRepository`'s own validation and read + * default): at runtime the argument was always a `TrekRepository` (every + * concrete repository extends it), so the guard always fired — this closed + * a typing gap, not a safety gap. The two per-call `disableIdentityMap: + * true` literals below are gone now that the base class's `find`/`findOne` + * overrides supply the same default. + * + * These helpers return live MikroORM entities, not rows — unlike every other + * repository method in this codebase (D4: "repositories return rows, never a + * live entity"). They are an internal building block for `TagsRepository`, + * which stays the actual public API and must run the result through `toRow` + * before it leaves the repository. + * + * Call-site convention (Task 0 re-review): type arguments are ALWAYS explicit — + * inference from the repository argument alone resolves `T` to the + * `{ id: unknown }` constraint and fails to compile (TS2345) — and the owner + * field is the RELATION property (`'user'`), never the `persist(false)` twin + * (`'user_id'`): both compile and emit the same SQL, but only the relation + * name survives a column rename and reads as the entity's API. `ownerId` is the + * raw FK number. Canonical calls: + * + * listForOwner(tags, 'user', userId, 'name'); + * findOwnedByUser(tags, id, 'user', userId); + * + * Both helpers get `disableIdentityMap: true` from `TrekRepository`'s own + * `find`/`findOne` default now (Plan 3b Task 1 fix round, supersedes Plan + * 3a's I1 "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): both are + * "rows out" reads whose entity is converted via `toRow` and discarded, never + * assigned to nor flushed, so a throwaway forked context is exactly right. + * + * Typing (Task 0 review, I2): `T extends { id: unknown }` and `K extends + * keyof T` tie `ownerField` to a real property of `T` and `ownerId` to + * `FilterValue` — the same value shape MikroORM's own `FilterObject` + * accepts for that property (`T[K]` directly is too strict for a relation + * property: `Tags['user']` is `Ref`, but every call site here filters + * it with the raw foreign-key id, which `FilterValue` allows and a bare + * `T[K]` would not). `id`/`ownerId` are no longer `unknown` — a call for an + * entity without an `id` column, or with an `ownerId` of the wrong shape, now + * fails `tsc` instead of only failing at runtime; the `as FilterQuery`/ + * `as OrderDefinition` casts are the one place the shape is still + * asserted rather than inferred, exactly at the computed-property-key + * boundary TypeScript cannot check on its own. + */ + +/** + * List every row owned by `ownerId`, ordered by `orderField` ascending: + * `SELECT * FROM
WHERE = ? ORDER BY ASC`. + */ +export async function listForOwner( + repo: TrekRepository, + ownerField: K, + ownerId: FilterValue, + orderField: OF, +): Promise { + return repo.find({ [ownerField]: ownerId } as FilterQuery, { + orderBy: { [orderField]: 'asc' } as OrderDefinition, + }); +} + +/** + * Find one row by id, strictly scoped to an owner: + * `SELECT * FROM
WHERE id = ? AND = ?`. + * + * `disableIdentityMap: true` per the module-level docstring above — this is + * always a "rows out" read here, never a caller's staging read for + * `assign`+`flush`. + */ +export async function findOwnedByUser( + repo: TrekRepository, + id: T['id'], + ownerField: K, + ownerId: FilterValue, +): Promise { + return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery); +} diff --git a/server/src/db/repositories/_shared/packing-visibility.ts b/server/src/db/repositories/_shared/packing-visibility.ts new file mode 100644 index 000000000..6bcf9ac98 --- /dev/null +++ b/server/src/db/repositories/_shared/packing-visibility.ts @@ -0,0 +1,149 @@ +import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely'; + +/** + * The per-actor visibility predicate packing's three-tier sharing model + * (#858) runs every restricted read/write through (Plan 3e Task 0, R1) — ONE + * source, typed, for `packing_items`. + * + * Derived byte-for-byte from `PackingService.VISIBLE_TO_ACTOR` + * (`server/src/nest/packing/packing.service.ts:347-351`, quoted here as the + * oracle this file's own parity harness proves against without importing + * it, the same "hold the STRING side fixed and independent of the + * production code it is proving" reasoning + * `reservation-visibility.test.ts`'s docstring gives): + * + * ``` + * ( + * is_private = 0 + * OR owner_id = ? + * OR EXISTS (SELECT 1 FROM packing_item_recipients r WHERE r.item_id = packing_items.id AND r.user_id = ?) + * ) + * ``` + * + * An item is visible to an actor when it is Common (`is_private = 0`, + * belongs to the whole trip), or the actor is its owner (the "bringer"), or + * the actor is a named recipient of a Shared-with-people item. This is + * `getItemInTrip`'s (PK11) entire security model — six call sites + * (`updateItem`, `setItemSharing`, `addContributor`, `removeContributor`, + * `cloneItem`, `deleteItem`) and `listItems`' viewer-filtered branch (PK5) + * all route through it. Per the inventory's §17 surprise 2: "the single + * highest-blast-radius conversion in the whole plan" — a mistranslation + * makes every restricted packing item on the instance readable/writable by + * any trip member. **No consumer converts in this task** (the brief, item + * 1) — Task 3 (packing) is the only consumer, and reuses this file's own + * parity harness (`packing-visibility.parity.test.ts`) rather than + * re-deriving it. + * + * **Not a template for `PackingItemsRepository.bagWeightTotals` + * (PK34/§17 surprise 10).** That aggregate deliberately sums + * `weight_grams` across every item in a trip regardless of privacy — it + * must NOT be built on top of either predicate below; building it that way + * would silently ADD a privacy filter the legacy statement never had, + * breaking the shared-bag weight-limit warning (the opposite failure mode + * from the one this file exists to prevent). + * + * **No actor collapses to "Common items only", never to "unfiltered".** + * `getItemInTrip` denies outright when `actorId == null` (its own doc + * comment: "A missing actor denies too, rather than falling through + * unfiltered") — that JS-level guard runs before any SQL, so the legacy + * fragment itself is never actually evaluated with a null actor in + * production. Both predicates below are proven safe anyway if that guard is + * ever bypassed (the harness's fourth actor cell): they accept + * `actorId: number | undefined` and, when it is `undefined`, omit the + * `owner_id`/recipient clauses from the `OR` entirely rather than binding a + * literal `null` into them. That is semantically identical to what the + * legacy fragment does when `?` is bound `NULL` — `owner_id = NULL` and + * `r.user_id = NULL` are SQL-NULL comparisons, always false, never true — + * so the `OR` reduces to `is_private = 0` either way. Binding a literal + * `null` through MikroORM's filter-object DSL was deliberately avoided + * instead: `{ owner_id: null }` there means `owner_id IS NULL` (a + * DIFFERENT, wrong predicate — it would make an unowned legacy item visible + * to a null actor), not `owner_id = NULL`, so the two are not + * interchangeable and only the "omit the clause" form is safe. + * + * **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2 + * split): packing's fragment is ONE OR-chain of three arms, always evaluated + * against the same, always-unaliased `packing_items` table (`PK5`'s + * `listItems` viewer branch and `PK11`'s `getItemInTrip` both select + * directly from `packing_items`, no alias) — there is no second alias shape + * to support the way reservations' `r`/`vr` split needed, so neither + * predicate below takes an `alias` parameter. + * + * - `packingVisibleToActorCondition` — a typed filter-object condition + * (inferred, not annotated `FilterQuery` — that broader + * union type includes the bare-primary-key shorthand MikroORM's filter + * DSL also accepts, which is not itself a spreadable object type, so + * annotating the return type with it breaks the `{ trip_id, ...cond }` + * spread every call site needs; the concrete inferred object literal type + * has no such member and spreads cleanly, the same reason + * `reservation-visibility.ts`'s `publicReservationCondition` leaves its + * own return type inferred too), spreadable into any + * `em.createQueryBuilder(PackingItems, ...).where({...})` call. Unlike RV2 + * (`reservation-visibility.ts`'s `publicStayExists`), the + * `EXISTS` arm here does NOT need Kysely to express as a QueryBuilder + * condition: `PackingItems.packing_item_recipients` is a genuine + * `p.manyToMany(Users)` relation over the `packing_item_recipients` pivot + * (`PackingItems.entity.ts`), unlike `reservations.accommodation_id` + * (RV2's blocker — a plain TEXT column with no FK/relation at all). Filtering + * that relation property with a scalar user id inside an `$or` renders as a + * `LEFT JOIN packing_item_recipients ... OR p1.user_id = ?` (verified + * directly against the compiled query, not assumed — `qb.getFormattedQuery()` + * on a seeded fixture) — safe against duplication because `(item_id, + * user_id)` is the pivot's own composite `PRIMARY KEY` + * (`Migration20200101022900_three_tier_packing_sharing.ts:17-21`), so at + * most one matching pivot row can join per item per actor, never fanning a + * row out. This is genuinely a `LEFT JOIN`, not an `EXISTS` subquery — + * proven row-identical to the legacy fragment by this file's own parity + * harness rather than assumed equivalent from the SQL shape alone. + * - `packingVisibleToActorExpr` — the Kysely-expression twin, for a caller + * building a raw, multi-join statement through `this.kysely()` rather than + * the MikroORM QueryBuilder. Spelled as a genuine correlated `EXISTS` + * (`eb.exists`), matching the legacy fragment's own shape token-for-token, + * rather than a join — Kysely has no ORM relation to lean on the way the QB + * form does, so this is built the same way `publicStayExists` is. + * + * Both forms are proven identical to the legacy fragment AND to each other + * on the same 12-cell fixture matrix (owner/recipient/stranger/no-actor × + * common/personal/shared) by `packing-visibility.parity.test.ts` in this + * directory. + */ + +export function packingVisibleToActorCondition(actorId: number | undefined) { + return { + $or: [ + { is_private: 0 }, + ...(actorId != null ? [{ owner_id: actorId }, { packing_item_recipients: actorId }] : []), + ], + }; +} + +/** + * `packing_items`/`packing_item_recipients`'s REAL table names — the shape + * `packingVisibleToActorExpr` needs. A consumer's own local Kysely `DB` + * interface satisfies this by structural extension, the same + * `ReservationVisibilityKyselyDB` precedent. + */ +export interface PackingVisibilityKyselyDB { + packing_items: { id: number; is_private: number; owner_id: number | null }; + packing_item_recipients: { item_id: number; user_id: number }; +} + +export function packingVisibleToActorExpr( + eb: ExpressionBuilder, + actorId: number | undefined, +): ExpressionWrapper { + const conditions: Expression[] = [eb('packing_items.is_private', '=', 0)]; + if (actorId != null) { + conditions.push(eb('packing_items.owner_id', '=', actorId)); + conditions.push( + eb.exists( + eb + .selectFrom('packing_item_recipients as r') + .select('r.item_id') + .whereRef('r.item_id', '=', 'packing_items.id') + .where('r.user_id', '=', actorId), + ), + ); + } + return eb.or(conditions); +} diff --git a/server/src/db/repositories/_shared/presence-set.ts b/server/src/db/repositories/_shared/presence-set.ts new file mode 100644 index 000000000..cb9cab1d1 --- /dev/null +++ b/server/src/db/repositories/_shared/presence-set.ts @@ -0,0 +1,35 @@ +/** + * Plan 3e Task 2 (budget), R11 — the presence-sentinel helper pattern. + * + * The legacy multi-column `UPDATE` statements this plan repeats five times + * (budget's 8-column item update / 6-column settlement update, packing's + * 8-column item update, todo's 7-column item update, collab's 6-column note + * update) all follow the same idiom: `col = CASE WHEN ? THEN ? ELSE col + * END`, bound with a presence flag and a value — "the caller didn't mention + * this field, leave the column alone" vs. "the caller set it, including to + * NULL, write that". + * + * Re-expressed for Kysely's `updateTable(...).set(data)`, no SQL-level CASE + * WHEN is needed: Kysely only emits a `SET` clause for a key that is + * PRESENT in `data` — an absent key is untouched, which IS the "ELSE col" + * branch, and a present key (even `null`) is written, which IS the "THEN ?" + * branch. `presenceSet` builds that object: pass each column a `[present, + * value]` pair (`value` already resolved to its final bound shape by the + * caller — e.g. `data.currency || null` — exactly like `ReservationsRepository + * .updateReservation`'s docstring: "the SERVICE resolves the field to its + * FINAL value first, this writes exactly what it is handed"), and only the + * present ones survive into the returned partial object. + * + * Land here first (Task 2); Tasks 3 (packing), 4 (todo) and 5 (collab) copy + * this shape rather than re-deriving their own CASE WHEN translation. + */ +export function presenceSet>( + fields: { [K in keyof T]?: readonly [present: boolean, value: T[K]] }, +): Partial { + const out: Partial = {}; + for (const key of Object.keys(fields) as (keyof T)[]) { + const entry = fields[key]; + if (entry && entry[0]) out[key] = entry[1]; + } + return out; +} diff --git a/server/src/db/repositories/_shared/reservation-travelers-owns.ts b/server/src/db/repositories/_shared/reservation-travelers-owns.ts new file mode 100644 index 000000000..06d85ec1b --- /dev/null +++ b/server/src/db/repositories/_shared/reservation-travelers-owns.ts @@ -0,0 +1,138 @@ +import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely'; + +/** + * The `TRAVELER_OWNS` predicate atlas's `AtlasService` inlines as a + * `private static readonly` string fragment (`atlas.service.ts:1173-1175`, + * quoted here as this file's own oracle, the same "hold the STRING side + * fixed and independent of the production code it is proving" reasoning + * `reservation-visibility.ts`/`packing-visibility.ts` document) — ONE + * shared, typed predicate for the three call sites (AT6/AT45/AT46) that all + * inline it today (Plan 3f Task 0, R7): + * + * ``` + * ( + * NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id) + * OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?) + * ) + * ``` + * + * Binds exactly one parameter (the caller's own user id) and expects the + * reservation aliased `r` — every one of AT6/AT45/AT46 already uses that + * alias for `reservations` in its `FROM`/`JOIN` clause, so both typed forms + * below fix it too rather than taking an arbitrary alias parameter (the + * `ReservationVisibilityKyselyDB & { a: { id: number } }` precedent + * `publicStayExists` sets for a single, real, already-fixed alias — not + * `reservation-visibility.ts`'s RV1 shape, which genuinely serves more than + * one alias in production, `r` AND `vr`). + * + * **Backward-compat critical (#1966).** A reservation with ZERO + * `reservation_travelers` rows — every booking made before 4.0, before + * per-traveler assignment existed at all — must still count for the WHOLE + * trip (the `NOT EXISTS` arm). Only once at least one assignment row exists + * does the second arm narrow the reservation to "assigned to me". Getting + * this backwards either double-counts a shared trip's flights per member + * (if the `NOT EXISTS` arm is dropped or inverted) or zeroes every pre-4.0 + * install's flown distance overnight (if the `EXISTS` arm is applied + * without the `NOT EXISTS` fallback) — the single highest-blast-radius + * predicate in this plan, per the plan's own risk list. **No consumer + * converts in this task** (the brief, item 5) — Task 1 (atlas) is the only + * consumer, at AT6/AT45/AT46, and reuses this file's own parity harness + * (`reservation-travelers-owns.parity.test.ts`) rather than re-deriving it. + * + * **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2 + * split) — `TRAVELER_OWNS` is ONE OR-chain of two arms, always evaluated + * against the same, always-aliased-`r` `reservations` row, so there is no + * second alias shape or second predicate to support the way RV1/RV2 needed. + * + * - `travelerOwnsCondition` — a typed MikroORM QueryBuilder filter-object + * condition, spreadable into any `.where({...})` call against `Reservations` + * (or any query whose base/joined entity is `Reservations`, referenced by + * its `reservation_travelers_collection` relation property — a genuine + * `p.oneToMany(ReservationTravelers).mappedBy('reservation')` relation, + * `Reservations.entity.ts:103`, unlike RV2's blocker where + * `reservations.accommodation_id` has no FK/relation at all to join + * through). Built with MikroORM's own collection operators (`$none`/`$some` + * — "collection operators, sql only", `node_modules/@mikro-orm/core/enums.d.ts`'s + * `QueryOperator` — verified directly, not assumed: `$exists` is a + * DIFFERENT operator that renders `IS NOT NULL` for an embeddable/JSON + * path, not a correlated subquery, which is exactly why RV2 has no QB + * form at all), which MikroORM's SQL query compiler + * (`node_modules/@mikro-orm/sql/query/ObjectCriteriaNode.js`) renders as + * ` IN (SELECT FROM reservations INNER JOIN reservation_travelers + * rt ON … WHERE rt.user_id = ?)` for `$some` and ` NOT IN (SELECT + * FROM reservations INNER JOIN reservation_travelers rt ON …)` for + * `$none` — a semicolon-for-semicolon DIFFERENT SQL shape from the legacy + * correlated `NOT EXISTS`/`EXISTS` text, but row-identical on every input + * (an `IN`/`NOT IN` against a `INNER JOIN`-then-`SELECT pk` subquery + * selects exactly the same primary-key set a correlated `EXISTS` would), + * proven directly by this file's own parity harness rather than assumed + * equivalent from the operator names alone. + * - `travelerOwnsExpr` — the Kysely-expression twin, for a caller building a + * raw, multi-join statement through `this.kysely()` rather than the + * MikroORM QueryBuilder (AT6/AT45/AT46 are exactly this shape — large, + * multi-table `JOIN` aggregates with `CASE WHEN` projections, never a + * plain `em.createQueryBuilder(Reservations, 'r')` a QB filter object could + * spread into on its own). Spelled as a genuine correlated `EXISTS`/`NOT + * EXISTS` pair, matching the legacy fragment's own shape token-for-token — + * the same "no ORM relation to join those rows the QueryBuilder could + * filter through" reasoning `publicStayExists` documents, even though a + * relation DOES exist here (Kysely never uses MikroORM's entity relations + * at all — it is a separate, relation-unaware SQL builder). + * + * Both forms are proven identical to the legacy fragment on the two cases + * that matter (a reservation with zero `reservation_travelers` rows; a + * reservation with assignments, narrowed to the caller's own) by + * `reservation-travelers-owns.parity.test.ts` in this directory. + */ + +export function travelerOwnsCondition(userId: number) { + return { + $or: [ + { reservation_travelers_collection: { $none: {} } }, + { reservation_travelers_collection: { $some: { user: userId } } }, + ], + }; +} + +/** + * `reservation_travelers`/`reservations`' REAL table names, the shape + * `travelerOwnsExpr` needs (fixed alias `r` for `reservations` — see this + * file's module docstring for why, unlike RV1's genuinely-multi-alias + * shape). A consumer's own local Kysely `DB` interface satisfies this by + * structural extension, the same `ReservationVisibilityKyselyDB`/ + * `PackingVisibilityKyselyDB` precedent. + */ +export interface ReservationTravelersOwnsKyselyDB { + reservation_travelers: { reservation_id: number; user_id: number }; + reservations: { id: number }; +} + +/** + * `TRAVELER_OWNS` as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND + * rt.user_id = ?))`, correlated on `rt.reservation_id = r.id`. Call inside a + * `.where((eb) => travelerOwnsExpr(eb, userId))` on a query already carrying + * an `r` alias for `reservations` (a base `.selectFrom('reservations as + * r')`, or `reservations` joined in as `r` — both produce the same aliased + * `r: { id: number }` member Kysely's type inference needs) against a + * `Kysely` whose `DB` satisfies `ReservationTravelersOwnsKyselyDB`. The + * parameter type is fixed to exactly that shape rather than generic over an + * arbitrary caller `DB`/`TB`, the same `publicStayExists` precedent and for + * the same reason (`Trips.repository.ts`'s `tripSelectQuery` docstring: a + * shared Kysely helper generic across differently-shaped outer queries + * doesn't compose), proven end-to-end by this file's own parity-harness + * test rather than assumed from the type signature alone. + */ +export function travelerOwnsExpr( + eb: ExpressionBuilder, + userId: number, +): ExpressionWrapper { + const forThisReservation = eb + .selectFrom('reservation_travelers as rt') + .select('rt.reservation_id') + .whereRef('rt.reservation_id', '=', 'r.id'); + + return eb.or([ + eb.not(eb.exists(forThisReservation)), + eb.exists(forThisReservation.where('rt.user_id', '=', userId)), + ]); +} diff --git a/server/src/db/repositories/_shared/reservation-visibility.ts b/server/src/db/repositories/_shared/reservation-visibility.ts new file mode 100644 index 000000000..b2428de03 --- /dev/null +++ b/server/src/db/repositories/_shared/reservation-visibility.ts @@ -0,0 +1,166 @@ +import type { Platform } from '@mikro-orm/core'; +import type { ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool } from 'kysely'; +import { coalesceParam } from '../../dialect/sql-functions'; + +/** + * The visibility predicate an anonymous viewer's reads are filtered + * through — ONE source, typed, for `reservations`/`day_accommodations` + * (Plan 3d Task 0, R3). + * + * Derived from the two legacy SQL-fragment builders this file superseded + * (`src/nest/reservations/reservation-visibility.ts`'s `publicReservationSql` + * and `publicStaySql` — DELETED by Plan 3d Task 4, once calendar and + * `share` were the string form's only remaining consumers and both were + * converted in the same commit: no two live implementations of an + * anonymous-surface security predicate, §18.2): + * + * - **RV1** `publicReservationSql(alias)` — `` COALESCE(${alias}.ingest_state, + * 'live') <> 'staged' ``. A reservation is public unless an automated + * ingest staged it for review. + * - **RV2** `publicStaySql(alias)` — a stay is public when nothing points at + * it (added by hand), or when a LIVE booking does: + * `(NOT EXISTS (…) OR EXISTS (… AND ))`, correlated on + * `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id` (`accommodation_id` + * is TEXT with no FK, §18.1 — some rows read back as `"14.0"`). + * + * THREE shapes are exported, because RV1 alone needs two different query + * APIs and RV2 needs a third that calls back into one of them. RV1 has no + * correlated subquery, so where a caller already holds a MikroORM + * QueryBuilder it renders as a typed filter OBJECT + * (`publicReservationCondition`) — spreadable into any `.where({...})` + * call at any table alias, exactly like the legacy fragment builder's own + * runtime-alias shape, no compile-time table binding needed (rule 23: a + * typed condition, not a string). Where a caller is instead building a raw, + * multi-join statement through `this.kysely()` (CL2/CL7/`share`'s reads — + * no ORM relation exists to join those rows through the QueryBuilder at + * all), RV1 is `publicReservationExpr`, a Kysely expression-builder + * predicate. RV2's NOT EXISTS/EXISTS pair has no MikroORM QueryBuilder + * expression either (no relation exists from `day_accommodations` to + * `reservations` via `accommodation_id` — R10 keeps it that way; QB's + * `$exists` operator checks embeddable/JSON path existence, not a + * correlated SQL subquery), so it is built with Kysely's own typed + * `ExpressionBuilder` (`publicStayExists`) — `eb.exists`/`eb.not` (Kysely's + * `unary('exists'|'not', …)` shortcuts), `eb.cast`, and `publicReservationExpr` + * itself for its own inner RV1 check, all fully typed, never a `raw()` call + * or a `sql` tagged template (both banned under `src/db/repositories/**` by + * ESLint's `no-restricted-syntax`, checked directly against + * `server/eslint.config.mjs`). + * + * **`publicStayExists` is deliberately narrow, not maximally generic** — + * `ReservationVisibilityKyselyDB` fixes the two aliases every known + * caller already uses (`a` for the outer stay row, `vr` for the + * correlated reservations lookup — "Alias interpolation is a + * caller-supplied constant ('r', 'vr', 'a'); never user input", inventory + * §2d). `Trips.repository.ts`'s `tripSelectQuery` docstring records why a + * shared Kysely helper across differently-joined outer queries doesn't + * compose well ("a shared private helper can't be typed across these four + * callbacks either… `leftJoin`'s alias widens each callback's own + * `ExpressionBuilder` table-set in a way a separate generic method + * signature can't match structurally") — the same limitation would bite a + * predicate meant to serve arbitrarily-different outer queries. This + * function instead requires exactly one contract: the caller's outer query + * is built with `.selectFrom('day_accommodations as a')` (or any + * `.selectFrom`/`.leftJoin` chain that puts an `a` alias with an `id` + * column into scope) and a `reservations` table exists in the same `DB` + * interface. A consumer whose own local Kysely `DB` interface satisfies + * `ReservationVisibilityKyselyDB` structurally can pass its `eb` straight + * through, extending its interface with `& ReservationVisibilityKyselyDB` + * if needed (the same additive-intersection shape + * `AssignmentTimeSortKyselyDB`/`RoadtripVisitsKyselyDB` already use for + * their own narrow, per-query interfaces) — proven end-to-end (not just + * typechecked) by this file's own test and by the string-vs-predicate + * parity harness (`tests/unit/db/repositories/_shared/reservation-visibility.test.ts`), + * which Tasks 2/4/5 reuse rather than re-derive. + * + * `ingest_state` visibility is deliberately NOT filtered for authenticated + * reads (`reservation-visibility.ts`'s own docstring: "the staging inbox + * has to see its own rows, or nobody can confirm them") — these two + * exports exist ONLY for the two anonymous surfaces (the ICS feeds, + * `share`) that need it; nothing in this file changes an authenticated + * list. + */ + +/** + * RV1 as a typed QB filter-object condition: + * `COALESCE(.ingest_state, 'live') <> 'staged'`. Spread into a + * `.where({...})` call — `{ ...publicReservationCondition(platform, 'r'), + * trip_id: tripId }` — at any table alias the caller's own query already + * established; this needs no compile-time table binding, the same way + * `coalesceParam` itself needs none (its `ref` argument is validated only + * by `column()`'s identifier-shape regex, not against a live entity's + * declared columns). + */ +export function publicReservationCondition(platform: Platform, alias: string) { + return { [coalesceParam(platform, `${alias}.ingest_state`, 'live')]: { $ne: 'staged' } }; +} + +/** + * RV1 as a Kysely predicate (L2, Plan 3d Task 0 review): the SAME + * `COALESCE(, 'live') <> 'staged'` boolean, spelled through the + * expression builder instead of a QB filter object — for the raw-SQL-shaped + * statements CL2/CL7/`share.service.ts`'s reads convert onto (no ORM + * relation joins those rows the QueryBuilder could filter through, so they + * are built with `this.kysely()`, same as `publicStayExists` below). + * `ref` is the caller's own already-aliased `ingest_state` column + * (`'r.ingest_state'`, `'vr.ingest_state'`) — generic over `` + * (unlike `publicStayExists`, this needs no correlated subquery and no + * table-set beyond whatever the caller already has in scope, so nothing + * forces a narrow, non-generic signature the way `publicStayExists`'s + * `ExpressionBuilder` does). + * `publicStayExists` below calls this rather than re-spelling the + * `COALESCE`/`<>` pair a second time — ONE implementation of RV1, not two + * (the Task 0 review's own finding, L2). + */ +export function publicReservationExpr( + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + return eb(eb.fn.coalesce(ref, eb.val('live')), '<>', eb.val('staged')); +} + +/** + * `reservations`/`day_accommodations`'s REAL table names, the shape + * `publicStayExists` needs. A consumer's own local Kysely `DB` interface + * (the `AssignmentTimeSortKyselyDB`/`RoadtripVisitsKyselyDB`/ + * `TripSelectKyselyDB` per-query-interface precedent) satisfies this by + * structural extension — declaring `day_accommodations`/`reservations` + * with at least these columns is enough; `Kysely` typed against it and + * queried via `.selectFrom('day_accommodations as a')` (the fixed alias + * every known legacy caller uses — see this file's module docstring) is + * what actually produces the aliased `a` member `publicStayExists` + * requires; Kysely computes that from THIS interface's `day_accommodations` + * entry, not from a hand-declared `a` key (a table's alias is a property of + * the QUERY, never of the `DB` interface itself). + */ +export interface ReservationVisibilityKyselyDB { + day_accommodations: { id: number }; + reservations: { id: number; accommodation_id: number | string | null; ingest_state: string | null }; +} + +/** + * RV2 as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND ))`, + * correlated on `CAST(vr.accommodation_id AS INTEGER) = a.id`. Call inside + * a `.where((eb) => publicStayExists(eb))` on a query already + * `.selectFrom('day_accommodations as a')` against a `Kysely` whose + * `DB` satisfies `ReservationVisibilityKyselyDB`. The parameter type is + * fixed to exactly the shape `.selectFrom('day_accommodations as a')` + * itself produces (`ReservationVisibilityKyselyDB` plus the derived `a` + * alias member) rather than generic over an arbitrary caller `DB`/`TB` — + * `Trips.repository.ts`'s `tripSelectQuery` docstring records why a shared + * Kysely helper generic across differently-shaped outer queries doesn't + * compose ("a shared private helper can't be typed across these four + * callbacks either… a separate generic method signature can't match + * structurally"); this function accepts exactly one contract instead, + * proven end-to-end by this file's own parity-harness test. + */ +export function publicStayExists(eb: ExpressionBuilder) { + const linkedTo = eb + .selectFrom('reservations as vr') + .select('vr.id') + .where((eb2) => eb2(eb2.cast(eb2.ref('vr.accommodation_id'), 'integer'), '=', eb2.ref('a.id'))); + + return eb.or([ + eb.not(eb.exists(linkedTo)), + eb.exists(linkedTo.where((eb2) => publicReservationExpr(eb2, 'vr.ingest_state'))), + ]); +} diff --git a/server/src/db/repositories/_shared/rows.ts b/server/src/db/repositories/_shared/rows.ts new file mode 100644 index 000000000..9da0a7597 --- /dev/null +++ b/server/src/db/repositories/_shared/rows.ts @@ -0,0 +1,40 @@ +import { wrap, type Collection, type EntityDTO, type Reference } from '@mikro-orm/core'; + +/** + * The plain row a service (and, through it, the API) sees. + * + * `toObject()` drops the ORM machinery — `Ref` wrappers, hidden relations, + * collections that were never loaded — and leaves the column-named scalars, + * which is exactly the `SELECT *` row the legacy code returned. Repositories + * return this, never a live entity, so a service cannot lazy-load or mutate + * state behind the repository's back. + */ +export function toRow(entity: T): EntityDTO { + return wrap(entity).toObject() as EntityDTO; +} + +/** + * The scalar (column-backed) keys of an entity class: everything that is not + * a relation or a collection. `Exclude` drops MikroORM's + * internal meta-properties (`[PrimaryKeyProp]`, `[OptionalProps]`, …) — + * `unique symbol`-keyed, declared on an entity whose primary key isn't a + * plain autoincrement `id` (e.g. `AppSettings`'s `[PrimaryKeyProp]?: 'key'`) + * — which are not columns and have no place in a hand-written row interface. + */ +export type ScalarKeys = { + [K in Exclude]-?: NonNullable extends Collection | Reference ? never : K; +}[Exclude]; + +/** + * Compile-time parity between a hand-written row interface and the entity's + * scalar keys: a column added to or removed from the entity fails `tsc` here + * instead of leaking silently through `toRow`'s cast. Types (nullability) + * stay hand-maintained in the interface on purpose — that is where the API + * shape is documented. + */ +export type AssertRowKeys = [ + Exclude>, + Exclude, keyof Row>, +] extends [never, never] + ? true + : never; diff --git a/server/src/db/repositories/_shared/trek-repository.ts b/server/src/db/repositories/_shared/trek-repository.ts new file mode 100644 index 000000000..6caba40a4 --- /dev/null +++ b/server/src/db/repositories/_shared/trek-repository.ts @@ -0,0 +1,270 @@ +import { + EntityRepository, + type CountByOptions, + type CountOptions, + type Cursor, + type DeleteOptions, + type Dictionary, + type EntityData, + type EntityKey, + type FilterQuery, + type FindAllOptions, + type FindByCursorOptions, + type FindOneOptions, + type FindOneOrFailOptions, + type FindOptions, + type GetKyselyOptions, + type Loaded, + type NativeInsertUpdateOptions, + type Primary, + type QueryBuilder, + type RequiredEntityData, + type SqlEntityManager, + type StreamOptions, + type UpdateOptions, + type UpsertManyOptions, + type UpsertOptions, + type WithUsingOptions, +} from '@mikro-orm/sql'; + +/** + * The one base class every repository in `src/db/repositories/*.ts` extends, + * instead of `@mikro-orm/sql`'s `EntityRepository` directly. + * + * **Why it exists.** Plan 3b Task 1's `disableIdentityMap` ruling + * (`Users.repository.ts`'s class docstring, task-1-review.md B1) made every + * row-out read pass `disableIdentityMap: true` to stop a stale, partially + * hydrated identity-map entity from being written back over an intervening + * `nativeUpdate`. The re-review (`task-1-rereview.md` F-R1) found the + * side effect: `disableIdentityMap: true` resolves its `EntityManager` with + * `getContext(false)` (`node_modules/@mikro-orm/core/EntityManager.js`, + * the `findOne`/`find` `disableIdentityMap` branch), and `false` is the one + * argument that SKIPS the `allowGlobalContext` check — + * `getContext(validate = true)` is the only place `ValidationError + * .cannotUseGlobalContext()` is thrown, gated on `validate` + * (`node_modules/@mikro-orm/core/EntityManager.js:1912-1925`). So every + * converted read stopped failing closed outside a request context: it now + * succeeds silently against the global `EntityManager` instead of throwing. + * Separately, the *write* paths (`nativeUpdate`/`nativeDelete`/`count`/ + * `insert`/`upsert`) never validated in the first place — they all resolve + * with `getContext(false)` too (task-1-review.md F7 INFO) — so an unwrapped + * entrypoint that only ever writes was never caught at all. + * + * This class restores the program's D6 ratchet ("an unwrapped entrypoint + * fails loudly") for every path a repository exposes, in one place: each + * override below calls {@link TrekRepository.validateRequestContext} FIRST — + * MikroORM's own, VALIDATING `getContext()` (`validate` defaults to `true`) + * — which throws `ValidationError.cannotUseGlobalContext()` outside + * `withRequestContext`/`RequestContext.create`/an open transaction, exactly + * as a managed (identity-map) read did before the `disableIdentityMap` + * ruling — and only then delegates to `super`. Every overridden method keeps + * MikroORM's own signature and does nothing else: this class does not change + * what a repository method returns or which SQL it emits, only whether it + * runs at all outside a request context. + * + * **Read-path default.** `find`/`findOne`/`findAll` additionally merge + * `{ disableIdentityMap: true }` into the caller's options, unless the + * caller passes `disableIdentityMap: false` explicitly — the class-level + * ruling every repository used to restate on its own `Users.repository.ts`, + * `McpTokens.repository.ts`, etc. — so no repository needs to pass the + * option itself any more. See + * https://mikro-orm.io/docs/entity-manager#disableidentitymap. + * + * **Kysely.** `em.getKysely()` (used for the one or two statements per + * repository the QueryBuilder cannot express — a recursive CTE, a + * `DELETE … RETURNING`) resolves its `EntityManager` with `getContext(false)` + * too (`node_modules/@mikro-orm/sql/SqlEntityManager.js`), so it has the + * exact same gap as the write paths above. {@link TrekRepository.kysely} is + * the one place a repository reaches Kysely from now on, instead of calling + * `this.getEntityManager().getKysely()` directly. + * + * See https://mikro-orm.io/docs/repositories (custom repositories) and + * https://mikro-orm.io/docs/identity-map (`allowGlobalContext`). + */ +export abstract class TrekRepository extends EntityRepository { + /** + * MikroORM's own validating `getContext()` — throws + * `ValidationError.cannotUseGlobalContext()` outside a request context + * (`allowGlobalContext: false`, the production default) or an open + * transaction. `super.getEntityManager()`, not `this.getEntityManager()`: + * the latter is itself overridden below to call this method, and calling + * through `this` here would recurse. + */ + protected validateRequestContext(): void { + super.getEntityManager().getContext(); + } + + /** + * `em.getKysely()`, validated first. The QueryBuilder has no recursive-CTE + * support and its `DELETE … RETURNING` is silently dropped + * (`WebauthnChallenges.repository.ts::claimChallenge`'s docstring), so a + * handful of methods across the program need this escape hatch — always + * through here now, never `this.getEntityManager().getKysely()` directly. + * No explicit return-type annotation: `SqlEntityManager.getKysely`'s + * return type is expressed in terms of `this` (the entity list the calling + * `EntityManager` was constructed with), which does not survive being + * spelled out via `ReturnType<...>` on its own — inference from the + * `return` statement below keeps it intact. + */ + protected kysely(options?: TOptions) { + this.validateRequestContext(); + return this.getEntityManager().getKysely(options); + } + + override getEntityManager(): SqlEntityManager { + this.validateRequestContext(); + return super.getEntityManager(); + } + + /** + * `find`/`findOne`/`findAll` below drop the base signature's `Using` + * type parameter and its `IndexFilterQuery`/`using` machinery (MikroORM's + * named-index query hint) rather than reproduce it: no repository in this + * program passes `using`, and TypeScript's override-compatibility check + * on that parameter's conditional type — `[Using] extends [never] ? ... : + * ...` compared against the base class's own instantiation of it — does + * not hold even for a byte-identical copy of the declared signature (a + * generic-defaults quirk in how the checker instantiates the two sides + * for comparison, not a real behavioural difference). Every other generic + * (`Hint`/`Fields`/`Excludes`, the ones every repository's `fields`/ + * `populate` options actually use) is preserved exactly. + */ + override async findOne( + where: FilterQuery, + options?: FindOneOptions, + ): Promise | null> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findOne(where, { ...options, disableIdentityMap }); + } + + override async find( + where: FilterQuery, + options?: FindOptions, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.find(where, { ...options, disableIdentityMap }); + } + + override async findAll< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + options?: WithUsingOptions, Entity, Using>, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findAll({ ...options, disableIdentityMap }); + } + + override async count(where?: FilterQuery, options?: CountOptions): Promise { + this.validateRequestContext(); + return super.count(where, options); + } + + /** + * Task 7 review, M4: `TrekRepository` left seven DB-reaching base-class + * methods unoverridden (none used anywhere in this program today — + * `grep -rnE "\.(countBy|findAndCount|findByCursor|findOneOrFail| + * insertMany|upsertMany|stream)\(" src/db/repositories/` was 0 hits — but + * the FIRST one used would silently lose both guarantees: no + * `validateRequestContext()`, and — for the read-shaped ones — no + * `disableIdentityMap: true` default). Same shape as `findOne`/`find`/ + * `findAll` above: `Using` dropped for the same override-compatibility + * reason documented on `findOne`. + */ + override async findOneOrFail( + where: FilterQuery, + options?: FindOneOrFailOptions, + ): Promise> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findOneOrFail(where, { ...options, disableIdentityMap }); + } + + override async findAndCount( + where: FilterQuery, + options?: FindOptions, + ): Promise<[Loaded[], number]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findAndCount(where, { ...options, disableIdentityMap }); + } + + override async findByCursor< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + IncludeCount extends boolean = true, + Using extends string = never, + >( + options: WithUsingOptions, Entity, Using>, + ): Promise> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findByCursor({ ...options, disableIdentityMap }); + } + + /** Grouping counts don't hydrate an entity — no identity map to isolate — so this only needs the fail-closed guard, matching `count` above. */ + override async countBy(groupBy: EntityKey | readonly EntityKey[], options?: CountByOptions): Promise> { + this.validateRequestContext(); + return super.countBy(groupBy, options); + } + + override stream( + options?: WithUsingOptions, Entity, Using>, + ): AsyncIterableIterator> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.stream({ ...options, disableIdentityMap }); + } + + override async insertMany(data: Entity[] | RequiredEntityData[], options?: NativeInsertUpdateOptions): Promise[]> { + this.validateRequestContext(); + return super.insertMany(data, options); + } + + override async upsertMany( + entitiesOrData?: EntityData[] | Entity[], + options?: UpsertManyOptions, + ): Promise { + this.validateRequestContext(); + return super.upsertMany(entitiesOrData, options); + } + + override async nativeUpdate(where: FilterQuery, data: EntityData, options?: UpdateOptions): Promise { + this.validateRequestContext(); + return super.nativeUpdate(where, data, options); + } + + override async nativeDelete(where: FilterQuery, options?: DeleteOptions): Promise { + this.validateRequestContext(); + return super.nativeDelete(where, options); + } + + override async insert(data: Entity | RequiredEntityData, options?: NativeInsertUpdateOptions): Promise> { + this.validateRequestContext(); + return super.insert(data, options); + } + + override async upsert( + entityOrData?: EntityData | Entity, + options?: UpsertOptions, + ): Promise { + this.validateRequestContext(); + return super.upsert(entityOrData, options); + } + + override createQueryBuilder(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.createQueryBuilder(alias); + } + + override qb(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.qb(alias); + } +} diff --git a/server/src/db/repositories/_shared/trip-access.ts b/server/src/db/repositories/_shared/trip-access.ts new file mode 100644 index 000000000..7dcd661e0 --- /dev/null +++ b/server/src/db/repositories/_shared/trip-access.ts @@ -0,0 +1,41 @@ +import type { ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool } from 'kysely'; + +/** + * The trip-access predicate — "can `userId` see this trip?" — as ONE Kysely + * boolean expression, generic over the caller's own `` (the same + * fully-generic shape `reservation-visibility.ts`'s `publicReservationExpr` + * uses, for the identical reason: this predicate has no correlated + * subquery, so nothing forces the narrower fixed-alias shape + * `publicStayExists` needs). + * + * Byte-for-byte the SAME boolean `Trips.repository.ts`'s MikroORM + * QueryBuilder helper `accessibleTripsQuery` renders (`findAccessible`'s own + * canonical shape, per Plan 4 Task 8a): `t.user_id = ? OR m.user_id IS NOT + * NULL`, where `m` is a `LEFT JOIN trip_members ON m.trip_id = t.id AND + * m.user_id = ?` the caller has already built. This file does not (and + * cannot, per `tripSelectQuery`'s own docstring on why a shared join helper + * can't be typed across differently-joined outer queries) unify the JOIN + * itself — only the WHERE boolean that follows it, which is the part that + * was actually duplicated three times by hand in this program (Task 7 + * security review M1 / TRIPREPO-041): `TripsRepository.findForViewer`, + * `.listForUser` and `.activeTrip` each re-typed the identical + * `eb.or([eb('t.user_id', '=', user_id), eb('m.user_id', 'is not', null)])` + * independently. Proven identical to the MikroORM form and across all three + * Kysely call sites by `TRIPREPO-041`'s cross-method access-parity test, + * which stays green as this refactor's own regression guard (its own + * comment already said a future unification pass should). + * + * `tripUserIdRef`/`memberUserIdRef` are the caller's own already-aliased + * column references (`'t.user_id'`, `'m.user_id'` at every known call site + * today) — passed in, not hard-coded, so this helper stays usable if a + * future caller joins under different aliases, the same reasoning + * `publicReservationExpr`'s own `ref` parameter gives. + */ +export function tripAccessExpr( + eb: ExpressionBuilder, + tripUserIdRef: ReferenceExpression, + memberUserIdRef: ReferenceExpression, + userId: number, +): ExpressionWrapper { + return eb.or([eb(tripUserIdRef, '=', userId), eb(memberUserIdRef, 'is not', null)]); +} diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts index ab4189cb3..a5a29b7f9 100644 --- a/server/src/db/reseat-booked-nights.ts +++ b/server/src/db/reseat-booked-nights.ts @@ -1,5 +1,5 @@ import Database from 'better-sqlite3'; -import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodations/night-seat'; +import { locatedIds, planViaCarry, sameOrder, seatAmong, type PinnedVia, type SeatRow } from '../nest/accommodations/night-seat'; /** * Seat every booked night where its check-in says, the way a night booked today is @@ -15,14 +15,16 @@ import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodatio * Only the stops a booking owns move (accommodation_id). A stop the traveller placed * themselves and then booked a night on is theirs, wherever it sits. The drawn roads * (roadtrip_vias) are pinned to positions among the day's located stops and are - * carried along by the rules the service applies (carryVias): a via follows the stop - * it was drawn after, a via behind a stop that is last stays with it, and one left - * without a leg goes. + * carried along by the rules the service applies (planViaCarry): a via follows the + * stop it was drawn after, a via behind a stop that is last stays with it, and one + * left without a leg goes. * - * Runs inside the migration's transaction. Re-runnable: the nights of a day are taken - * in the order the rule seats them (a night without a check-in first, then by - * check-in, ties by booking), so one pass leaves every night in its seat and a second - * pass moves nothing. Returns how many nights moved. + * Runs inside the migration's transaction, over the raw connection: the rule is + * night-seat.ts's, the statements are this file's, the way the services' are their + * repositories'. Re-runnable: the nights of a day are taken in the order the rule + * seats them (a night without a check-in first, then by check-in, ties by booking), + * so one pass leaves every night in its seat and a second pass moves nothing. Returns + * how many nights moved. */ export function reseatBookedNights(db: Database.Database): number { const nights = db.prepare(` @@ -31,12 +33,27 @@ export function reseatBookedNights(db: Database.Database): number { JOIN day_accommodations a ON a.id = da.accommodation_id ORDER BY da.day_id, a.check_in IS NOT NULL, a.check_in, a.id `).all() as Array<{ id: number; day_id: number; accommodation_id: number; check_in: string | null }>; + // A day's stops as the rule sees them (SeatRow): the same statement + // DayAssignmentsRepository.listSeatRows (AC7) runs for the services. + const dayStops = db.prepare(` + SELECT da.id, da.order_index, + CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, + other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located + FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC + `); const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?'); + const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?'); + const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?'); + const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?'); + const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?'); let seated = 0; const roads = { moved: 0, removed: 0 }; for (const night of nights) { - const rows = dayStops(db, night.day_id); + const rows = dayStops.all(night.day_id) as SeatRow[]; const own = rows.findIndex((row) => row.id === night.id); if (own < 0) continue; const others = rows.filter((row) => row.id !== night.id); @@ -46,11 +63,17 @@ export function reseatBookedNights(db: Database.Database): number { next.forEach((row, i) => setIndex.run(i, row.id)); seated += 1; - const carried = carryVias(db, night.day_id, locatedIds(rows), locatedIds(next)); - if (carried) { - roads.moved += carried.moved; - roads.removed += carried.removed; - } + + const before = locatedIds(rows); + const after = locatedIds(next); + if (sameOrder(before, after)) continue; + const plan = planViaCarry(viasOf.all(night.day_id) as PinnedVia[], before, after); + if (!plan) continue; + for (const viaId of plan.remove) dropVia.run(viaId, night.day_id); + for (const via of plan.moved) moveVia.run(via.after_order_index, via.id, night.day_id); + for (const via of plan.resequence) setSequence.run(via.sequence, via.id, night.day_id); + roads.moved += plan.moved.length; + roads.removed += plan.remove.length; } if (roads.moved || roads.removed) { console.log(`[DB] Re-pinned ${roads.moved} drawn road(s) behind the reseated nights, dropped ${roads.removed} left without a leg`); diff --git a/server/src/db/schema-snapshot.ts b/server/src/db/schema-snapshot.ts new file mode 100644 index 000000000..ea2720e83 --- /dev/null +++ b/server/src/db/schema-snapshot.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * A migrated-but-empty database, serialised once per test run. + * + * Migrations are async and `database.ts::initDb()` is not — it runs at module + * load, long before anything can await. Most suites do not care (they mock the + * module or build their own database), but a handful use the singleton directly + * and need a schema to be there the moment they import it. + * + * So the vitest global setup migrates one database for the whole run and writes + * it here; every worker opens a copy. `better-sqlite3` deserialises a 1.5 MiB + * snapshot in well under a millisecond, against ~800ms to replay 242 migrations, + * so this is also what keeps the suite fast. + * + * Production never reads this — there `buildApp()` provides the await point. + */ + +/** Keyed by the migration set, so a snapshot from another branch is never reused. */ +function snapshotKey(): string { + const dir = path.join(__dirname, 'migrations'); + // A missing directory (e.g. a partial dist) degrades to a constant key; the + // global setup rewrites the file either way. + const names = fs.existsSync(dir) ? fs.readdirSync(dir).sort() : []; + return crypto.createHash('sha256').update(names.join('\n')).digest('hex').slice(0, 16); +} + +/** Outside the repo on purpose: nothing here belongs in a working tree or an image. */ +export function schemaSnapshotPath(): string { + return path.join(os.tmpdir(), `trek-schema-${snapshotKey()}.db`); +} + +export function writeSchemaSnapshot(snapshot: Buffer): void { + fs.writeFileSync(schemaSnapshotPath(), snapshot); +} + +/** The snapshot bytes, or null when no run has produced one. */ +export function readSchemaSnapshot(): Buffer | null { + try { + return fs.readFileSync(schemaSnapshotPath()); + } catch { + return null; + } +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts deleted file mode 100644 index 1df32e9ae..000000000 --- a/server/src/db/schema.ts +++ /dev/null @@ -1,619 +0,0 @@ -import Database from 'better-sqlite3'; - -function createTables(db: Database.Database): void { - db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - maps_api_key TEXT, - unsplash_api_key TEXT, - amap_api_key TEXT, - openweather_api_key TEXT, - avatar TEXT, - oidc_sub TEXT, - oidc_issuer TEXT, - last_login DATETIME, - mfa_enabled INTEGER DEFAULT 0, - mfa_secret TEXT, - mfa_backup_codes TEXT, - immich_url TEXT, - immich_access_token TEXT, - synology_url TEXT, - synology_username TEXT, - synology_password TEXT, - synology_sid TEXT, - must_change_password INTEGER DEFAULT 0, - password_version INTEGER NOT NULL DEFAULT 0, - feed_token TEXT, - is_guest INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - consumed_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); - CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); - - CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - credential_id TEXT NOT NULL UNIQUE, - public_key BLOB NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - transports TEXT, - device_type TEXT, - backed_up INTEGER NOT NULL DEFAULT 0, - name TEXT, - aaguid TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); - - CREATE TABLE IF NOT EXISTS webauthn_challenges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - challenge TEXT NOT NULL UNIQUE, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - expires_at INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); - - CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT, - UNIQUE(user_id, key) - ); - - CREATE TABLE IF NOT EXISTS trips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - start_date TEXT, - end_date TEXT, - currency TEXT DEFAULT 'EUR', - cover_image TEXT, - is_archived INTEGER DEFAULT 0, - reminder_days INTEGER DEFAULT 3, - feed_token TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS days ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL, - date TEXT, - notes TEXT, - title TEXT, - UNIQUE(trip_id, day_number) - ); - - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT '📍', - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#10b981', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - reservation_status TEXT DEFAULT 'none', - reservation_notes TEXT, - reservation_datetime TEXT, - place_time TEXT, - end_time TEXT, - duration_minutes INTEGER DEFAULT 60, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - website TEXT, - phone TEXT, - transport_mode TEXT DEFAULT 'walking', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS place_tags ( - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (place_id, tag_id) - ); - - CREATE TABLE IF NOT EXISTS place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(place_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS day_assignments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - order_index INTEGER DEFAULT 0, - notes TEXT, - reservation_status TEXT DEFAULT 'none', - reservation_notes TEXT, - reservation_datetime TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS packing_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - checked INTEGER DEFAULT 0, - category TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - file_size INTEGER, - mime_type TEXT, - caption TEXT, - taken_at TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS trip_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL, - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - file_size INTEGER, - mime_type TEXT, - description TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS reservations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, - title TEXT NOT NULL, - accommodation_id TEXT, - reservation_time TEXT, - reservation_end_time TEXT, - location TEXT, - confirmation_number TEXT, - notes TEXT, - status TEXT DEFAULT 'pending', - type TEXT DEFAULT 'other', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS trip_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - invited_by INTEGER REFERENCES users(id), - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS day_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - text TEXT NOT NULL, - time TEXT, - icon TEXT DEFAULT '📝', - sort_order REAL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value TEXT - ); - - CREATE TABLE IF NOT EXISTS budget_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'Other', - name TEXT NOT NULL, - total_price REAL NOT NULL DEFAULT 0, - persons INTEGER DEFAULT NULL, - days INTEGER DEFAULT NULL, - note TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Addon system - CREATE TABLE IF NOT EXISTS addons ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - type TEXT NOT NULL DEFAULT 'global', - icon TEXT DEFAULT 'Puzzle', - enabled INTEGER DEFAULT 0, - config TEXT DEFAULT '{}', - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS photo_providers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - icon TEXT DEFAULT 'Image', - enabled INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS photo_provider_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, - field_key TEXT NOT NULL, - label TEXT NOT NULL, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER DEFAULT 0, - secret INTEGER DEFAULT 0, - settings_key TEXT, - payload_key TEXT, - sort_order INTEGER DEFAULT 0, - UNIQUE(provider_id, field_key) - ); - - -- Vacay addon tables - CREATE TABLE IF NOT EXISTS vacay_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - block_weekends INTEGER DEFAULT 1, - holidays_enabled INTEGER DEFAULT 0, - holidays_region TEXT DEFAULT '', - school_holidays_enabled INTEGER DEFAULT 0, - company_holidays_enabled INTEGER DEFAULT 1, - carry_over_enabled INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(owner_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_plan_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(plan_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_user_colors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - color TEXT DEFAULT '#6366f1', - UNIQUE(user_id, plan_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_years ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, - UNIQUE(plan_id, year) - ); - - CREATE TABLE IF NOT EXISTS vacay_user_years ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, - vacation_days INTEGER DEFAULT 30, - carried_over INTEGER DEFAULT 0, - UNIQUE(user_id, plan_id, year) - ); - - CREATE TABLE IF NOT EXISTS vacay_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date TEXT NOT NULL, - note TEXT DEFAULT '', - UNIQUE(user_id, plan_id, date) - ); - - CREATE TABLE IF NOT EXISTS vacay_company_holidays ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - date TEXT NOT NULL, - note TEXT DEFAULT '', - UNIQUE(plan_id, date) - ); - - CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - type TEXT NOT NULL DEFAULT 'public_holiday', - region TEXT NOT NULL, - label TEXT, - color TEXT NOT NULL DEFAULT '#fecaca', - sort_order INTEGER NOT NULL DEFAULT 0 - ); - - -- Collections addon tables - CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT 'Bookmark', - cover_image TEXT, - links TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending', - role TEXT NOT NULL DEFAULT 'editor', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - osm_id TEXT, - website TEXT, - phone TEXT, - status TEXT NOT NULL DEFAULT 'idea', - source_trip_id INTEGER, - source_place_id INTEGER, - links TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_place_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_place_tags ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, tag_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); - - -- Per-collection custom labels (distinct from the instance-wide tags table): - -- each list defines its own labels and every place can carry several. - CREATE TABLE IF NOT EXISTS collection_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_labels ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, label_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id); - - CREATE TABLE IF NOT EXISTS day_accommodations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - check_in TEXT, - check_in_end TEXT, - check_out TEXT, - confirmation TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Collab addon tables - CREATE TABLE IF NOT EXISTS collab_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - category TEXT DEFAULT 'General', - title TEXT NOT NULL, - content TEXT, - color TEXT DEFAULT '#6366f1', - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collab_polls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - question TEXT NOT NULL, - options TEXT NOT NULL, - multiple INTEGER DEFAULT 0, - closed INTEGER DEFAULT 0, - deadline TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collab_poll_votes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - option_index INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(poll_id, user_id, option_index) - ); - - CREATE TABLE IF NOT EXISTS collab_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - text TEXT NOT NULL, - reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); - `); - - db.exec(` - CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id); - CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id); - CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id); - CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id); - CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id); - CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id); - CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id); - CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id); - CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id); - CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id); - CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id); - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id); - - CREATE TABLE IF NOT EXISTS assignment_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(assignment_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id); - - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - resource TEXT, - details TEXT, - ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); - - CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), - scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), - target INTEGER NOT NULL, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title_key TEXT NOT NULL, - title_params TEXT DEFAULT '{}', - text_key TEXT NOT NULL, - text_params TEXT DEFAULT '{}', - positive_text_key TEXT, - negative_text_key TEXT, - positive_callback TEXT, - negative_callback TEXT, - response TEXT CHECK(response IN ('positive', 'negative')), - navigate_text_key TEXT, - navigate_target TEXT, - is_read INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS notification_channel_preferences ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - channel TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (user_id, event_type, channel) - ); - CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); - - CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL); - `); -} - -export { createTables }; diff --git a/server/src/db/seeders/AddonSeeder.ts b/server/src/db/seeders/AddonSeeder.ts new file mode 100644 index 000000000..cb7488365 --- /dev/null +++ b/server/src/db/seeders/AddonSeeder.ts @@ -0,0 +1,163 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface AddonRow { + id: string; + name: string; + description: string; + type: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +const DEFAULT_ADDONS: AddonRow[] = [ + { + id: 'packing', + name: 'Lists', + description: 'Packing lists and to-do tasks for your trips', + type: 'trip', + icon: 'ListChecks', + enabled: 1, + sort_order: 0, + }, + { + id: 'budget', + name: 'Costs', + description: 'Track and split trip expenses', + type: 'trip', + icon: 'Wallet', + enabled: 1, + sort_order: 1, + }, + { + id: 'documents', + name: 'Documents', + description: 'Store and manage travel documents', + type: 'trip', + icon: 'FileText', + enabled: 1, + sort_order: 2, + }, + { + id: 'vacay', + name: 'Vacay', + description: 'Personal vacation day planner with calendar view', + type: 'global', + icon: 'CalendarDays', + enabled: 1, + sort_order: 10, + }, + { + id: 'atlas', + name: 'Atlas', + description: 'World map of your visited countries with travel stats', + type: 'global', + icon: 'Globe', + enabled: 1, + sort_order: 11, + }, + { + id: 'mcp', + name: 'MCP', + description: 'Model Context Protocol for AI assistant integration', + type: 'integration', + icon: 'Terminal', + enabled: 0, + sort_order: 12, + }, + { + id: 'naver_list_import', + name: 'Naver List Import', + description: 'Import places from a shared Naver Maps list', + type: 'integration', + icon: 'Link2', + enabled: 1, + sort_order: 13, + }, + { + id: 'collab', + name: 'Collab', + description: 'Notes, polls, and live chat for trip collaboration', + type: 'trip', + icon: 'Users', + enabled: 1, + sort_order: 6, + }, + { + id: 'roadtrip', + name: 'Road trip', + description: 'Drives with stops along the route, driving times, and arrival times that update themselves', + type: 'trip', + icon: 'Route', + enabled: 0, + sort_order: 7, + }, + { + id: 'journey', + name: 'Journey', + description: 'Trip tracking & travel journal — check-ins, photos, daily stories', + type: 'global', + icon: 'Compass', + enabled: 0, + sort_order: 35, + }, + { + id: 'airtrail', + name: 'AirTrail', + description: 'Sync flights from your AirTrail instance', + type: 'integration', + icon: 'Plane', + enabled: 0, + sort_order: 14, + }, + { + id: 'dawarich', + name: 'Dawarich', + description: + 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', + type: 'integration', + icon: 'Dawarich', + enabled: 0, + sort_order: 17, + }, + { + id: 'llm_parsing', + name: 'AI Parsing', + description: 'LLM fallback for booking imports kitinerary cannot read', + type: 'integration', + icon: 'Sparkles', + enabled: 0, + sort_order: 15, + }, + { + id: 'collections', + name: 'Collections', + description: + 'Personal place library — save places across trips into named lists, copy into any trip, share with others', + type: 'global', + icon: 'Bookmark', + enabled: 0, + sort_order: 16, + }, +]; + +/** + * Seeds the addon shelf. + * + * `INSERT OR IGNORE` per row, not a table-level emptiness check: an operator who + * has toggled an addon keeps their choice, and a release that adds a new addon + * still gets its tile on an existing install. + */ +export class AddonSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + for (const addon of DEFAULT_ADDONS) { + await connection.execute( + 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', + [addon.id, addon.name, addon.description, addon.type, addon.icon, addon.enabled, addon.sort_order], + ); + } + console.log('Default addons seeded'); + } +} diff --git a/server/src/db/seeders/AdminSeeder.ts b/server/src/db/seeders/AdminSeeder.ts new file mode 100644 index 000000000..61be9b4c2 --- /dev/null +++ b/server/src/db/seeders/AdminSeeder.ts @@ -0,0 +1,104 @@ +import { readEnv } from '../../app-config'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; + +/** bcrypt cost factor for the seeded admin password — kept in sync with authService. */ +const BCRYPT_COST = 12; + +/** + * Seeds run before the DB admin panel can be used, so only env vars are checked + * here. The granular password_login/password_registration DB toggles are only + * relevant after the first user exists; by then this seeder has already skipped + * via the userCount guard. + */ +function isOidcOnlyConfigured(): boolean { + const oidc = readEnv().oidc; + if (!oidc.only) return false; + return !!(oidc.issuer && oidc.clientId); +} + +/** + * Creates the first-run admin account. + * + * Only ever on an empty `users` table — `ADMIN_EMAIL`/`ADMIN_PASSWORD` are a + * first-run bootstrap, not an ongoing setting, and a later run says so out loud + * rather than silently ignoring them. + */ +export class AdminSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + try { + const env_admin_email = readEnv().adminBootstrap.email; + const env_admin_pw = readEnv().adminBootstrap.password; + const adminEnvProvided = !!(env_admin_email || env_admin_pw); + + const rows = (await connection.execute('SELECT COUNT(*) as count FROM users')) as { count: number }[]; + if (rows[0].count > 0) { + // A common trip-up: people add the vars after the fact, restart, nothing + // changes, and there is no hint why. Say so instead of staying silent. + if (adminEnvProvided) { + console.warn( + '[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.', + ); + console.warn( + '[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.', + ); + } + return; + } + + // Demo mode seeds its own admin (admin@trek.app, username 'admin') right + // after this. Creating a first-run admin here would grab username 'admin' + // first and make the demo seeder fail on the UNIQUE(username) constraint. + if (readEnv().demo.enabled) return; + + if (isOidcOnlyConfigured()) { + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — OIDC-Only Mode ║'); + console.log('║ First SSO login will become admin. ║'); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + return; + } + + let password: string; + let email: string; + if (env_admin_email && env_admin_pw) { + password = env_admin_pw; + email = env_admin_email; + } else { + // A partial config (only one of the two) is an easy mistake: neither + // value is used and a generated password is created instead. Flag it so + // the chosen credentials silently not working isn't a surprise. + if (adminEnvProvided) { + console.warn( + '[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).', + ); + } + password = crypto.randomBytes(12).toString('base64url'); + email = 'admin@trek.local'; + } + + const hash = bcrypt.hashSync(password, BCRYPT_COST); + await connection.execute( + 'INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)', + ['admin', email, hash, 'admin'], + ); + + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — First Run: Admin Account Created ║'); + console.log('╠══════════════════════════════════════════════╣'); + console.log(`║ Email: ${email.padEnd(33)}║`); + console.log(`║ Password: ${password.padEnd(33)}║`); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + } catch (err: unknown) { + console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err); + } + } +} diff --git a/server/src/db/seeders/CategorySeeder.ts b/server/src/db/seeders/CategorySeeder.ts new file mode 100644 index 000000000..527f89c11 --- /dev/null +++ b/server/src/db/seeders/CategorySeeder.ts @@ -0,0 +1,40 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** The starter categories a fresh install gets, in display order. */ +const DEFAULT_CATEGORIES: { name: string; color: string; icon: string }[] = [ + { name: 'Hotel', color: '#3b82f6', icon: '🏨' }, + { name: 'Restaurant', color: '#ef4444', icon: '🍽️' }, + { name: 'Attraction', color: '#8b5cf6', icon: '🏛️' }, + { name: 'Shopping', color: '#f59e0b', icon: '🛍️' }, + { name: 'Transport', color: '#6b7280', icon: '🚌' }, + { name: 'Activity', color: '#10b981', icon: '🎯' }, + { name: 'Bar/Cafe', color: '#f97316', icon: '☕' }, + { name: 'Beach', color: '#06b6d4', icon: '🏖️' }, + { name: 'Nature', color: '#84cc16', icon: '🌿' }, + { name: 'Other', color: '#6366f1', icon: '📍' }, +]; + +/** + * Seeds the default place categories. + * + * All-or-nothing on an empty table, exactly as `seeds.ts` did: once anything + * exists the operator owns the list, and re-adding a category they deleted would + * be the seeder overruling them. + */ +export class CategorySeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + const existing = (await connection.execute('SELECT COUNT(*) AS count FROM categories')) as { count: number }[]; + if (existing[0].count > 0) return; + + for (const category of DEFAULT_CATEGORIES) { + await connection.execute('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)', [ + category.name, + category.color, + category.icon, + ]); + } + console.log('Default categories seeded'); + } +} diff --git a/server/src/db/seeders/DatabaseSeeder.ts b/server/src/db/seeders/DatabaseSeeder.ts new file mode 100644 index 000000000..b54d68ad2 --- /dev/null +++ b/server/src/db/seeders/DatabaseSeeder.ts @@ -0,0 +1,21 @@ +import { AddonSeeder } from './AddonSeeder'; +import { AdminSeeder } from './AdminSeeder'; +import { CategorySeeder } from './CategorySeeder'; +import { DocumentProviderSeeder } from './DocumentProviderSeeder'; +import { PhotoProviderSeeder } from './PhotoProviderSeeder'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; +import { SchemaVersionSeeder } from './SchemaVersionSeeder'; + +/** + * The default seeder, replacing `db/seeds.ts::runSeeds()`. + * + * Order matters in one place only: `AdminSeeder` runs first because it is the + * one that keys off an empty `users` table, and the provider seeders run after + * `AddonSeeder` so the shelf exists before anything hangs off it. + */ +export class DatabaseSeeder extends Seeder { + async run(em: EntityManager): Promise { + await this.call(em, [AdminSeeder, CategorySeeder, AddonSeeder, PhotoProviderSeeder, DocumentProviderSeeder, SchemaVersionSeeder]); + } +} diff --git a/server/src/db/seeders/DocumentProviderSeeder.ts b/server/src/db/seeders/DocumentProviderSeeder.ts new file mode 100644 index 000000000..f49aeeb7b --- /dev/null +++ b/server/src/db/seeders/DocumentProviderSeeder.ts @@ -0,0 +1,32 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** + * Seeds the document providers. + * + * The rows come from `document-provider-seed.ts`, the same constants the + * migration that introduces the tables uses, so a fresh install and an upgraded + * one end up with identical rows. + */ +export class DocumentProviderSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + + for (const p of DOCUMENT_PROVIDERS) { + await connection.execute( + 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await connection.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/seeders/PhotoProviderSeeder.ts b/server/src/db/seeders/PhotoProviderSeeder.ts new file mode 100644 index 000000000..1006d8e11 --- /dev/null +++ b/server/src/db/seeders/PhotoProviderSeeder.ts @@ -0,0 +1,193 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface PhotoProviderRow { + id: string; + name: string; + description: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +interface PhotoProviderFieldRow { + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder: string | null; + hint: string | null; + required: 0 | 1; + secret: 0 | 1; + settings_key: string | null; + payload_key: string; + sort_order: number; +} + +const PROVIDERS: PhotoProviderRow[] = [ + { id: 'immich', name: 'Immich', description: 'Immich photo provider', icon: 'Image', enabled: 0, sort_order: 0 }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + }, +]; + +/** `label` and `hint` are i18n key suffixes the client resolves as `memories.