mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fe35e26313dbaa65372b59669173a2f86e331fbb
231 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
806f3c8e62 |
refactor(addons): the transit-provider setting and photo-provider routes move into the shared kernel
AddonsService read the transit backend through transit/transit-provider.ts and built photo-provider endpoint paths with memories.helpers, so the addons domain, which nearly every domain imports for its gates, depended on transit and memories. Both are free functions over a repository or plain strings; they move unchanged to common/, where the transit and admin sides read them too. |
||
|
|
da97d7535f |
test(server): follow the signatures the session and port work changed
The session gate passes a session lookup to verifyJwtAndLoadUser, the Immich save takes auto_upload, getAppUrl stays synchronous, the demo session list marks the current session by the id it is handed, and the lease no longer needs a transaction of its own, so its spec is gone. |
||
|
|
37af6f4ff9 |
test(server): seed bookings and places the way the services store them
The converted specs wrote accommodation_id as a plain id, while the services store the legacy '<id>.0' text, and wrote read-only foreign key twins instead of their relations. The files e2e schema gains the trip columns the entity fills, and the idempotency spec seeds a real user. |
||
|
|
be016f180e |
test(server): seed the session, lease and rollback specs through the factories
The specs written alongside the session, transaction and lifecycle work still seeded and read with raw statements. They go through the ORM factories now; the few that test SQL below the ORM carry a test-sql-allow reason. The raw SQL baseline holds 177 statements in 6 files. |
||
|
|
e90ed065e5 |
test(server): clear and age fixture rows through the ORM in converted specs
These specs left the raw SQL baseline but still emptied tables with
`db.exec('DELETE FROM ...')` and aged a Dawarich connection with a raw
UPDATE, which the ratchet cannot see because it only counts `.prepare(`.
They now go through deleteRows and updateRows like the rest of the file.
packing_item_recipients and packing_bag_members have no entity; both
cascade from their item and bag, so the packing spec relies on that with
foreign keys on in the snapshot database.
|
||
|
|
63bcf61a2b |
test(server): seed the system notice, packing e2e and synology specs through the factories
Converts the raw prepare() fixtures and reads in the system notices integration suite, the packing e2e and the synology memories integration suite to the ORM factories and readers. The packing e2e moves from its hand-rolled tables to the migrated snapshot, seeds its three users with the ids their session cookies are signed for, and drops the raw canAccessTrip on its database mock, which nothing reads any more. The synology suite now shares one ORM handle for the photo registration helpers and the readers instead of declaring a second one halfway down the file. |
||
|
|
e9ff28d84a |
test(server): seed the files MCP and roadtrip e2e specs through the factories
Converts the raw prepare() fixtures and reads in the files MCP spec and the roadtrip e2e to the ORM factories and readers. The files spec seeds its trip files through makeTripFile and its links through linkFile, keeping the helper's explicit-null handling for size and type. The roadtrip e2e keeps its pinned trip, day and place ids, which its routes address directly, now written through insertRow and insertRows. |
||
|
|
9104dab183 |
test(server): seed the photo cache and dawarich specs through the factories
Converts the raw prepare() fixtures and reads in the trek photo cache and google place photo cache services and the dawarich e2e to the ORM factories and readers. The place photo cache suite moves from its hand-rolled three-table fixture to the migrated snapshot: the references it checks are now real places on a trip and real saved places in a collection, seeded through makePlace and makeCollectionPlace, which a table without names or owners could not hold. The dawarich e2e drops the raw canAccessTrip on its database mock, which nothing reads any more. |
||
|
|
855c4e3ba3 |
test(server): seed the collections, storage registry, transit, token and immich specs through the factories
Converts the raw prepare() fixtures and reads in the collections e2e, the storage registry service spec, the transit MCP spec, the MCP token repository spec and the immich memories integration suite to the ORM factories and readers. The collections e2e drops the raw canAccessTrip on its database mock, which nothing reads any more. Reads that used to join trip_photos to trek_photos in SQL resolve the photo row through the readers instead, and the orphan trip photo of IMMICH-057 is still written with foreign keys switched off, now through the ORM on the same connection. |
||
|
|
a7fe990c6e |
test(server): seed the plugin settings, tag, rating, day and receipt specs through the factories
Converts the raw prepare() fixtures and reads in the plugin settings
isolation spec, the tags MCP spec, the place ratings and days repository
specs and the receipt scan e2e to the ORM factories and readers.
The receipt scan e2e writes the AI parsing addon config as the object the
entity's JSON column maps, not as a pre-serialised string, and seeds its
users and trips through makeUser and makeTrip with the ids its session
cookie is signed for. The days spec keeps the SELECT * row it compares the
ORM's rows with key for key, and its legacy statements, behind allow
markers; the place ratings spec backdates updated_at with dbNow instead of
datetime('now').
|
||
|
|
7349ee95c2 |
test(server): seed the atlas repository and storage admin specs through the factories
The visited countries and place regions repository specs seed their rows through the ORM; the legacy statements they hold the repositories to stay raw behind an allow marker, since those are the oracles the parity is checked against. The storage admin e2e moves from hand-rolled tables to the migrated snapshot, seeds its two users through the factories with the ids their session cookies are signed for, and reads the stored backends and the audit rows through the readers instead of raw SELECTs. |
||
|
|
9de9719a02 |
test(server): seed the share, quota, photo and stats specs through the factories
Converts the raw prepare() fixtures and reads in the share and place shadow
e2e suites, the google quota, travel stats and MCP guard services, the
memories MCP spec, the admin seeder spec and the assignment participants
repository spec to the ORM factories and readers.
The place shadow e2e moves from hand-rolled tables to the migrated snapshot
with its users pinned to the ids their session cookies are signed for. The
share e2e drops the raw canAccessTrip it still put on the database mock:
nothing has read it since trip access moved onto the repositories. The
quota spec computes yesterday and the retention window in UTC, the same
calendar day SQLite's date('now') gives. The participants oracle keeps its
raw statement behind an allow marker.
|
||
|
|
e3982a8e2a |
test(server): seed the repository, MCP and e2e specs through the factories
Converts the raw prepare() fixtures and reads in the next set of specs to the ORM factories and readers: the password reset, webauthn, dawarich, budget, to-do and vacay repository specs, the journey, settings, addons and share MCP specs, the plugin POI and operator egress specs, the feeds, storage stats and query helper services, and the packing, collections, day reorder, leg mode, upload context and trip access integration suites. The notifications and google quota e2e suites now seed their users through makeUser with pinned ids, and the notifications suite moves from hand-rolled tables to the migrated snapshot. Legacy statements that serve as a parity oracle and the raw rows the primary-relation spike holds toObject() against stay raw, each with an allow marker naming why. |
||
|
|
894a5ee781 |
test(server): seed the small leftover specs through the repositories
Converts the raw prepare() reads and writes in the share, airtrail, files, todo, dev-link, vacay, tours, bucket list, unit-of-work and plugin guard specs to the ORM factories and readers, so they go through the entities the app uses instead of hand-written SQLite. The vacay e2e moves from hand-rolled tables to the migrated snapshot, with its user pinned to the id its session cookie is signed for. Statements that are raw by nature keep the allow marker with the reason: the legacy oracle of the bucket list parity spec, the PRAGMA probe of the trek_photos migration, the unmapped probe table of the route import rollback, the behind-the-ORM reads of the entity insert probes, and seedUser, which also serves the hand-rolled users table of createTempDb. Comments that quoted a prepare() call are reworded so the ratchet no longer counts them. |
||
|
|
5947631db6 |
test(server): seed the days, tours, assignments and journey share specs through the factories
The days and tours e2e suites, the assignments service suite and the journey share suite build their fixtures through the ORM factories and read back through findRow/findRows/countRows. Fixtures that pin ids (the trip, day and places a session cookie or a route targets) keep them. Readers that the old suites called synchronously (order, dayOrder, viaAnchors, insertJourneyPhoto) are async and awaited, Promise.all where a case compares several at once. Left raw and marked: the legacy statements the parity cases hold the repository reads to, the COLLATE NOCASE lookups that prove the token match is exact, and total_changes(), which counts writes on the connection itself. |
||
|
|
a3781ed27b |
test(server): seed the collab, budget, MCP and OAuth token specs through the factories
The collab and budget e2e suites, the MCP integration suite, the budget MCP tools and the OAuth token repository suite seed and read through the ORM factories. The collab e2e suite moves off its hand-rolled CREATE TABLE set onto the migrated snapshot, the way the factories guide asks of a suite that converts: an entity reads and writes every mapped column, which a trimmed table does not have. Its users are seeded with the ids the session cookies are signed for. The recursive chain statement in the token suite stays raw, marked as the legacy oracle collectChainIds is held to. |
||
|
|
49ba73fcae |
test(server): seed the atlas, settings, budget and notification specs through the factories
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
|
||
|
|
e2ce2ad05c |
test(server): seed the service, MCP and route specs through the ORM factories
Service, MCP, integration and e2e suites with up to a dozen raw statements now seed and read through the factories. A buildApp() suite uses the app's own ORM, a partial-module e2e or a unit suite a createTestOrm() or sharedTestOrm() handle on its database. Reads that used to come back undefined now come back null, and the assertions say so; where a projected SELECT was compared whole, the same columns are picked off the row. Two reads inside the scheduler's delivery callback stay synchronous raw statements, marked, so they keep seeing the row at the moment of delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in the module no longer exports. |
||
|
|
b2f2aef016 |
test(server): seed the small specs through the repositories
The suites with one to three raw statements left now seed and read their rows through the ORM factories instead of hand-written SQLite on the better-sqlite3 handle. A legacy statement that serves as a parity oracle, the schema introspection and the bare backup file keep their raw SQL and say why with a test-sql-allow marker. Two database mocks still answered a getPlaceWithTags the module no longer exports; that dead branch goes too, and the budget seam suite stubs the reservations repository directly instead of a fake prepare(). |
||
|
|
ba28b5538d |
test(server): keep the legacy TEXT shape of a booking's stay link
reservations.accommodation_id is TEXT, and every writer stores a stay id in the shape the legacy statement bound it (`14.0`, see legacyBoundIntegerText). The converted specs wrote and matched the bare `14` instead, so a lookup of a booking the service had linked would miss it and a seeded link would not look like one the app writes. They now go through legacyBoundIntegerText, the same helper the services use. |
||
|
|
583ba17606 |
test(server): seed the e2e specs through the repositories
The e2e suites for assignments, places, bookings, trips, the public API, doc sync, addons, transit, to-dos, admin, invite links, school holidays, categories, feeds and plugin search seeded and inspected their database with raw better-sqlite3 statements. They now go through the ORM factories and readers, the way the repositories write and read. Six of them (public API, transit, to-dos, admin, invite links, categories) still built a handful of hand-rolled tables. They move to the migrated snapshot, since an entity reads and writes every mapped column; where the real schema keeps usernames unique, the seeded users get distinct names and keep the ones the assertions read back. The dead canAccessTrip stubs left in two database mocks are dropped: nothing in src imports it from db/database any more. |
||
|
|
1bba65634c |
test(server): seed and read test data through ORM factories
The server suite seeds and reads its database through some 5,500 raw better-sqlite3 statements on the sync handle. They restate the schema by hand, skip the repositories the app goes through and run on SQLite only, so nothing in the suite could ever exercise the dialect layer on another engine. tests/helpers/factories/ adds typed factories per domain (users, trips and days, places, assignments, bookings, budget, packing, to-dos, files, journeys, tours, collections, notifications, settings, plugins, tokens, atlas, vacay, photos, collab) and generic readers (findRow, findRows, countRows, updateRows, deleteRows) that go through MikroORM in a request context of their own, against the app's ORM or a createTestOrm handle. They refuse a write to a read-only foreign-key twin, which MikroORM would otherwise drop without a word. The README there is the conversion guide. The categories integration suite and the tags and calendar-feed e2e suites are converted in full as worked examples; the tags suite also moves from hand-rolled tables to the migrated snapshot. lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left to a per-file baseline: growth and stale entries fail, files whose subject is raw SQL are exempt with a reason, and a single statement can be allowed with a test-sql-allow comment. CI runs it next to lint:boundaries. |
||
|
|
be751ad521 |
fix(auth): start no new session for a password change over a Bearer token
A password change ends every session of the user and re-issues one for the device it was made on, handed out as the session cookie. A caller that authenticated with a Bearer token never reads that cookie, so the new session sat in the session list as a live device nobody held until it expired. The controller now asks for the replacement session only when the request came with the session cookie, the same check the sliding renewal makes. The response body is unchanged. |
||
|
|
648f63c626 |
fix(sessions): keep an ended session's row until it expires
A session renewed from a token issued before sessions were tracked has an id derived from that token, and the renewal inserts it only when no row by that id exists. The nightly purge deleted revoked rows straight away, so the next request with the still valid old token inserted the session again as an active one and got a fresh tracked token with a new lifetime. A logout, an ended session or a sign out of the others held only until the purge ran. The purge now deletes expired rows only, and a revoked row stays until its own expiry. A derived row is recorded with an expiry no earlier than the old token's, so by the time it goes the old token is refused by its signature check anyway. |
||
|
|
e94525afd3 |
test(sessions): cover every path that ends a session end to end
The session e2e suite drove login, logout, the session routes, a password change and renewal, but nothing showed end to end that a password reset, an MFA disable, account deletion or an admin action makes the old cookie stop working, the purge job had only a unit test with a stubbed service, and reset-admin.js had no test of its session handling at all. sessions.e2e.test.ts now also resets a password through the mailed token (both devices get 401, every row is ended, the new password signs in), turns two-factor off from one device (the other gets 401, this one stays), deletes the account (the rows go with it) and runs SessionPurgeJob.tick against the real table (ended and expired rows go, the live session keeps working). The admin paths need AdminModule and with it most of the app, so session-revocation.e2e.test.ts boots buildApp(): an admin setting a password, clearing two-factor and deleting a member each sign both of the member's devices out and leave the admin signed in, while an edit that leaves the password alone signs nobody out. reset-admin-sessions.test.ts runs the script against a database with session rows (only the reset account's live ones end) and against one without the table. |
||
|
|
0dde01baa2 |
fix(oidc): sign in after SSO on a success flag, not the deprecated token
The token in the sign-in bodies was declared deprecated and "read by
nothing of ours", but the web client still decided an SSO sign-in by
reading data.token from GET /api/auth/oidc/exchange, an endpoint the
deprecation notice left out. Removing the field later, as documented,
would have broken SSO sign-in.
The exchange now answers { success: true, token } and the login hook
signs in when the response is OK and success is true, so it never reads
token. The field stays for API clients and is now on the deprecation
list in the API docs, the controller comments, server/CLAUDE.md and the
wiki (Security-Hardening, OIDC-SSO). Adding a field to the body breaks
no existing client.
|
||
|
|
1ad6ae0ebc |
fix(auth): keep demo visitors out of each other's sessions
On a demo instance every visitor signs in as the one shared demo account, so its sessions are other people's browsers. The session routes had no demo check, unlike the password, MFA and account changes, so any visitor could read the User-Agent of everyone else signed in and sign them all out with revoke-others. For the shared demo account in demo mode, GET /api/auth/sessions now lists only the session the request came with, and DELETE /api/auth/sessions/:id and POST /api/auth/sessions/revoke-others answer 403 "Sessions cannot be ended in demo mode." without ending anything. Logout still ends the visitor's own session. Other accounts on a demo instance keep the full routes. No MCP tool covers sessions, so there is no parallel tool to change. The demo sign-in now records the device's User-Agent on its session like every other sign-in, which the session list needs to tell the visitor's own entry apart. |
||
|
|
9aab9e7513 |
feat(auth): track sign-in sessions so a session can end before it expires
A session token was a signed JWT and nothing else. Logout only cleared the cookie, a 30-day "remember me" token stayed valid wherever a copy of it had ended up, and nobody could sign out one device short of changing the password or rotating the JWT secret for everyone. Every session token now carries an id (jti) naming a row in the new user_sessions table (id, user_id, created_at, last_seen_at, expires_at, revoked_at, user_agent cut to 256 characters; no address is kept). The rows live behind UserSessionsRepository and the new sessions domain, whose SessionsService is the one issuer: AuthService.generateToken and the OIDC sign-in both go through it, and sliding renewal re-signs a tracked token under the same id and moves its expiry. verifyJwtAndLoadUser takes the repository and refuses a token whose session is revoked, expired or gone, refreshing last_seen_at at most every five minutes. A token without a jti was issued before this change; it passes until its own expiry (or a password change), so the upgrade signs nobody out, and renewal turns it into a tracked session. Sessions end on logout (the one the request names, verified even when expired), on a password change (all, the device gets a new one), a password reset, an admin setting a password, reset-admin.js, an MFA disable (all but the current one), an admin clearing a user's MFA, and with the account. GET /api/auth/sessions lists the active sessions, DELETE /api/auth/sessions/:id ends one and POST /api/auth/sessions/revoke-others ends all but the current, with their contracts in @trek/shared and two new audit actions. There is no UI for them yet. MCP exposes no account or session tools, so there is no parallel tool to change; an MCP call made with a session JWT as bearer goes through the same check. The token in the login, register, demo-login, MFA and passkey bodies stays for API clients but is marked deprecated in the API docs and in the client type, which never read it. A nightly session-purge job on CronRegistrarService deletes the expired and revoked rows. The migration is 042500. The sessions domain gets its own coverage entry at the 80 floor until a full run measures it. |
||
|
|
9cebadf67c |
fix(memories): save the Immich connection and auto_upload in one transaction
PUT /api/integrations/memories/immich/settings saved the connection through one service call and then wrote auto_upload through a second one from the controller, each committed on its own. A failure between them left a new connection with the old upload choice. saveImmichSettings takes the auto_upload flag and writes it with the connection (or the disconnect) inside one transaction, after the SSRF check that does the DNS lookup. An absent or non-boolean flag still keeps the stored choice, a refused URL still writes nothing, and the response is unchanged. The controller's write sequence leaves the lint:tx baseline, and the MemoriesService facade loses the immichSetAutoUpload pass-through it no longer needs. No MCP tool writes these settings. |
||
|
|
f40d92a69e |
fix(trips): write the budget currency rebase with the trip row
TripsService.update rebased the budget onto the new currency in its own transaction and only then wrote the trip row in a second one. When the day grid rebuild, the booking resync or the leave shift threw after the rebase had committed, budget items and settlements stayed pinned and re-frozen against a currency the trip never took (#1543). rebaseTripCurrency is split into prepareCurrencyRebase, which fetches the rates before any transaction opens, and applyCurrencyRebase, which reads the outgoing currency and writes the rebase. update() fetches first and hands the plan to updateTrip, which applies it inside the transaction that writes the row, the leave shift and the day grid. updateTrip called without a plan (the plugin RPC host) still never rebases, and rebaseTripCurrency keeps working as before. The updateTrip docstring no longer claims the row is written outside the day regeneration transaction. TripsService.update leaves the lint:tx baseline, and the size baselines shrink with the files. |
||
|
|
b3a085df3f |
refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between processes replace the in-memory one, but they could not. The readers outside the container (the broadcast functions, the backup restore's permissions-cache flush, the pending-code sweep, hand-built consumers) went straight to the module-level in-memory instances, so an overridden provider was used by the gateway and the services while the broadcasts and the restore flush kept using the old store. And every port was synchronous, while a store in the database or on the network is async, so plugging one in would have changed every signature anyway. RateLimitStore, PendingCodeStore, OidcFlowStore and PermissionsCacheStore now return promises, and RateLimitService, OauthService, OidcService, PermissionsService and their callers await them. The in-memory stores still decide synchronously, so who gets a 429, a code or a cached level is unchanged. RoomRegistry stays synchronous: membership holds live sockets and broadcast() is a synchronous call every domain makes, so several processes need a fan-out in front of broadcast(), not a different place for the sets. The process-wide ports get a ProcessStoreSlot. PermissionsModule, OauthModule and RealtimeGatewayModule install the store the container resolved and release it on shutdown, and the free functions, the sweep and hand-built consumers read the slot, so overriding the provider changes every reader. Tests provide fake stores and check that the broadcasts, the restore flush and the sweep use them. Two callers needed more than an await. The transit MCP tools counted in a RateLimitService of their own that no provider could reach; they now take the one RateLimitModule provides (their mcp_transit_* buckets never overlap the REST ones). The link preview claimed its in-flight slot only after the budget check, which now yields, so simultaneous askers would each have been charged; it now claims the slot first, and an asker that joined someone out of budget asks again on its own budget. The unused putPendingCode/takePendingCode helpers are gone. |
||
|
|
0aa5b36661 |
fix(backup): back up and restore through an injected database port
Backup, restore and the demo reset each handled the SQLite file themselves: backup.impl closed and reinitialized the connection, built MaintenanceRepository with new, and assumed the database was data/travel.db. That tied the whole chain to one local file and hid two bugs. They now go through DATABASE_BACKUP, a port declared in the shared database kernel (snapshot, verify, keep a copy before a restore, replace). SqliteDatabaseBackup implements it with what the code did before: VACUUM INTO for a consistent copy, and a close, copy, sidecar drop, rename and reopen through DatabaseLifecycle for a restore. BackupService injects it and hands it to backup.impl. The demo reset and the baseline save use the same port; the first baseline is saved by DemoResetJob once the app is up, because the boot-time seed runs before anything can be injected. Another engine can plug in a logical dump behind the port. Fixes on the way: - The backup and the restore ignored TREK_DB_FILE. The backup looked for data/travel.db and, with the database elsewhere, silently left it out of the archive; the restore wrote data/travel.db and never replaced the live database. Both now use DatabaseLifecycle.file, the path the connection runs on, and the pre-restore copy sits next to it. - A failed VACUUM INTO fell back to archiving the live WAL-mode file without a word, the torn copy the snapshot exists to prevent. The backup now fails: nothing is archived or committed, the existing backups are untouched, a partial snapshot is removed, and the log says why. A missing database file and a failed checkpoint are logged too. |
||
|
|
c956fe92ba |
Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database. |
||
|
|
de8caa81a2 |
Feat/orm : mikroorm (#2416)
* 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 <DB, TB> 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 (
|
||
|
|
af7dbce105 | fix(tours): prevent duplicate day assignments | ||
|
|
9e475f447a | feat(tours): add non-motorized day tour planning | ||
|
|
62950aed24 | test(e2e): hand-made schemas follow the new columns; placing entries never blocks the refresh | ||
|
|
7acf7f5655 |
feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582) |
||
|
|
94447fa011 | test(public-api): the other stats suites stub next_trip too | ||
|
|
ba3dc65926 |
feat(public-api): next_trip in /api/v1/stats counts down to the next trip
The counterpart of last_trip for a homepage widget: the nearest trip that has not started yet, with its countries and the whole days until it starts, or null when nothing is planned. A trip under way stays last_trip, so the two never name the same trip. (#2542) |
||
|
|
f5103d04e0 |
test: follow the qol sweep in the e2e schemas and the assertions it changed
The hand-written e2e schemas gain the new columns and place_regions, and the unit assertions take the new arguments and fields. The Public API page names GET /api/auth/app-config for the running version. |
||
|
|
8fd46c4c18 |
feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore. |
||
|
|
b1c05bca83 |
feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it lands with the two features that send a photo, so it has something to control. The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On Automatic a local Ollama is asked what the model can do (`/api/show`), and a cloud provider means no; Yes and No are applied as set. The old `multimodal` flag is not read: the admin screens wrote it false on every save, so a stored false meant nothing. The per-user toggle keeps its on/off meaning and is read by the extraction now. The lookup is server-side only: capped read, validated body, never throws, cached per server and model. GET /api/llm/capabilities answers it for the signed-in caller, and both photo pickers below ask it. The booking import takes JPG, PNG and WEBP when the model reads images. The photo goes as an image to every provider (image_url, an Anthropic image block, Ollama's native chat with `images`), capped at 1600px, and skips KDE Itinerary. A scanned PDF, which used to reach the model as empty text, has its first two pages drawn by pdf-parse and sent the same way. The rest is the import as it was: background job, review, the file attached to the booking, the linked cost. On a real qwen3.5:4b a train ticket came back as bare clock times until the router gave the model every type's field meanings, and its price as an object inside a string, which read as 4900; both are handled. Costs gets Scan receipt: a dialog laid out like the booking import's takes one photo, read on the import job queue, then reviewed in the existing expense editor pre-filled with merchant, total, currency, day and lines, the photo waiting in Receipts & Invoices to be uploaded on save. No table, no migration, no category guess, no document-type detection. |
||
|
|
d1dc8542ac |
fix(costs): leave out an expense no rate is known for instead of counting it 1:1
A foreign expense without a frozen rate is converted with today's rate. When the server could not fetch rates itself, it counted the amount 1:1 in the trip currency, although the browser had the rates all along. A VND bill on an AUD trip was settled as millions of AUD. Such a row is now left out of the balances, flows, final budgets, trip totals and per-person summaries. The settlement reports it under `unconverted` and says which currency its figures are in; the trip summary lists the ids and the budget prompt says how many are missing. The browser lends the rates it already holds (`fallback_fx`) when it saves an expense or a settle-up transfer, and to heal existing rows through the new POST .../budget/freeze-rates route, which an editor's Costs view calls for the rows the settlement left out. The server still prefers its own rate, uses a lent one only while it is quoted against the trip currency, and never replaces a frozen or explicit rate. The settlement also takes the browser's display rate (`base_rate`) in place of a display quote the server cannot fetch; the browser leaves out a figure the query would refuse. The browser's own fetch gives up after 10 s. Changing a row's currency while no rate is available no longer keeps the old currency's rate; the row goes back to unfrozen. MCP gets the same through the new freeze_budget_rates tool, which only ever pins the server's own rate. |
||
|
|
44a12e21fc |
fix(costs): read an expense entered in the display currency as it was typed (#2525)
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked costs are labelled in the trip currency (every linked row, the link picker and the phone's linked costs), a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch. |
||
|
|
6ed2561c63 |
fix(notifications): show the header logo in Gmail and Outlook (#2507)
The logo at the top of every TREK mail was an SVG inlined into the HTML as a data: URI. Gmail strips data: URIs, Outlook blocks them and neither renders SVG, so notification and password-reset mails showed a broken image with "TREK" as its alt text. The image data itself was fine; it only rendered in clients that accept both data: URIs and SVG, such as Apple Mail. The logo is now a 96x96 PNG, rasterized from client/public/icons/icon.svg with the corner radius baked in, and MailerService attaches it to both HTML sends as an inline part. The header img points at it by cid:, so nodemailer puts the HTML and the image into multipart/related. That is the form Gmail, Outlook and Apple Mail all display, and it does not depend on the instance being reachable from the recipient's mail provider. The plain-text admin test mail carries no HTML and stays without an attachment. |
||
|
|
ae73527362 |
fix(atlas): a place that moves no longer keeps its old country (#2527)
place_regions caches the country and region Atlas resolved for a place, and a row that was already there was never re-derived. Correcting a place's location in the trip left the old row in charge, so Atlas, the dashboard stats and the journey stats kept counting the country the place had just left, with the trip's dates on it. A trigger on places now drops the cached row whenever lat, lng or address actually change, so every writer is covered: the place editor, the MCP update tools, the plugin RPC and the import enrichment and address backfill. An edit that writes the same location back keeps the row. The background lookup that fills the cache also only writes while the place still sits where it was resolved from. A place moved while its lookup was running would otherwise get the old answer written back. Places corrected before this fix would have kept their old country until they were moved again, because the trigger only sees new edits. PlaceRegionsRepairJob goes over place_regions once, on the first start after the update, and remembers that in app_settings. It re-derives each row from the bundled borders only, never Nominatim, and replaces a row whose country or region no longer matches where its place is now. A row the bundle cannot answer for is left alone unless both the coordinates and the address put the place in another country; then it is dropped and the next Atlas load looks the place up again. A place without coordinates keeps no row. Its writes are guarded the same way as the background lookup, so a place edited during the pass is not overwritten. The in-memory region cache is now keyed by the country the address names as well as the coordinates, so correcting only the address of a border point no longer returns the old answer until a restart. On dev the trigger takes schema version 244, the slot it has on main, so a 4.3.3 install upgrading to 4.4.0 does not skip it. Web Push moves up to 245 and is idempotent, so a 4.3.3 install gets its table there. An instance that already ran Web Push at 244 never runs 244 again, so 246 creates the trigger once more; everywhere else that is a no-op. |
||
|
|
c9dfe9c688 |
fix(pwa): recover from a dead chunk and a broken precache after an update (#2524)
After an update, a tab or a device whose service worker still serves the previous build asks for chunks the update removed. The server answered those requests with index.html and a 200, so the import failed with a MIME error, and everything caching in between was free to keep the HTML as the chunk. A precache install that met a missing file stored it, and Workbox reuses hashed entries by URL, so the device showed "A new version is available" on every load, whatever the reload or the cleared browser cache, and carried the broken entry into every later release that keeps the file's name. The SPA fallback now answers a GET for a build file that is not on disk with a plain 404 (no-store) instead of index.html. A precache install refuses it, so the worker whose cache is complete stays in charge until the new build is. index.html is sent relative to the public dir, which also stops an install living under a dot directory from answering every deep link with a 404. On the client, the reload after a dead chunk (the automatic one and the button on the update screen) no longer goes straight back to the worker that served the broken shell. When that same worker is still in charge and the server answers, its precache and registration are dropped first, so the reload reaches the build the server runs now. Offline, and in offline mode, the shell is kept. Every recovery reload, and the reload of the version handover, asks for the page under a one-off query parameter, so a proxy or CDN that keeps index.html against its headers cannot hand back the stale shell either. A chunk the entry imports statically fails before any of that code runs. shell-guard.js, a small classic script loaded ahead of the entry, watches for exactly that failure and runs the same recovery, once per session. A stylesheet, font or icon that holds a page raises no error at all, so once the app has started and the browser is idle, it looks through the precache for entries that hold a page under a build file's name, fetches each from the server past every cache and puts it back in place. A repaired stylesheet is loaded again, and so are the app's stylesheets when a font was repaired, since the browser does not try a face that failed again in the same document. Only the headers are read, a precache found sound is not looked at again until its entries change, nothing is fetched while nothing is broken, and nothing happens offline, in offline mode or without storage. An answer that is not the file (an auth wall, its login page, a server error) leaves the entry for the next launch. A page the recovery loaded from the network already runs the version the server reports, so the version handover now records it instead of reloading it a second time once the new worker takes over. A page loaded past an older worker that is still in charge, as a hard reload does, still asks for the new worker and records the version only once that has taken over, so the next tab or launch cannot get the old shell without a handover. Dropping the worker ends a Web Push subscription with it, so on such a device the settings card shows push as off until it is switched on again. A missing sw-push.js now fails the worker install with a 404 rather than index.html; the comments that described the old answer say so. |
||
|
|
8864edda18 |
feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could not be tied to the booking it belongs to, and a booking held one expense. - The Costs block of the booking, transport and place forms lists every linked expense with edit, unlink (the expense stays in Costs) and delete, and offers the expenses that belong nowhere yet under Link existing expense. The phone sheets get the same block in their own design, and their Files row lists the attached files and links an existing one. - PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC) take reservation_id / place_id; create and update refuse an id from another trip on all three paths. - The booking's mirrored price is the sum of its expenses when they share one currency (none counts as the trip's), else the first one, with that currency, and follows every change to a linked expense. The booking form's price field leaves a booking with several expenses alone. - Deleting a booking deletes all of its linked expenses and broadcasts each one, also from the MCP delete tools. - Cards show the price with its currency. - Booking and transport dialogs: Booking Type and Travelers as dropdowns at the top, the URL beside Files (transports gain the URL field). The row is one shared component; linking and unlinking files go through the store. Help center pictures and texts for the booking, transport and expense guides are redone in all 27 languages, five Costs labels that were still English in 16 languages are translated, and the wiki follows. |
||
|
|
2a8071f642 |
feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside search(). Its places then show up in the place search's typed-ahead list, after the core suggestions and marked with the plugin's name, on desktop and phone. An index that cannot take a request per keystroke leaves it out and keeps answering the full search only. The child reports which functions each hook object carries at load, so GET /api/plugin-search/suggest only asks the plugins that implement suggest: from the second character, three rows at most across all of them, 800 ms to answer. The feed marks plugins holding hook:search-provider and the client only asks the route while one is installed. A plugin row carries its whole place, so picking it skips the details lookup, which knows no plugin id. The phone's search results now name their source as the desktop ones do, and the desktop dropdown scrolls instead of running past the dialog. Addresses discussion #2221. |