mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
3e1234112de0dae5fffc7a4d90ec7794eb5c94e0
1238 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3e1234112d |
fix(demo): save the first baseline after a reopen that seeded too
The demo seed runs in the schema bootstrap, at boot and again after every reopen. Since the baseline save moved out of the seed into DemoResetJob, only the boot read the seed's mark. A restore onto a demo instance with no baseline, whose archive held no admin trips, seeded the example trips during the reopen and then saved nothing, so the hourly reset kept logging "No baseline found"; the mark also stayed set for the rest of the process. Before the move, that path saved the baseline from inside the seed. DatabaseLifecycle now runs listeners after a reopen's schema bootstrap, and DemoResetJob registers one in demo mode that does the same check as at boot: save through the backup port when the seed just ran and no baseline exists. Each check clears the mark. A failed save is logged, never turned into a failed reopen. AdminModule imports DatabaseLifecycleModule from the shared database kernel for it. |
||
|
|
b658b5e68d |
docs(server): say why the admin domain does not import DatabaseBackupModule
DatabaseBackupModule stays @Global, but its comment gave a vague reason. The real one is concrete: the backup domain already reaches admin through auth and settings, so importing the module from AdminModule would add a domain cycle that lint:boundaries rejects. The module comment now says that and points at the e2e harness that imports it by hand, and AdminModule notes where its DATABASE_BACKUP comes from. |
||
|
|
175f5d96da |
fix(backup): report a lost connection when the swap and its reopen both fail
SqliteDatabaseBackup.replace reopens the connection in a finally block, so it runs even when the swap throws. A reopen that failed there was stored and then dropped: the swap's exception propagated past the return that would have carried it. The demo reset caught that swap error, logged "Failed to restore baseline" and returned normally, leaving the instance without a database connection and nothing in the log to say so. Before the port, the same branch awaited reinitialize() and its rejection reached the tick's log line. replace now throws a DatabaseConnectionLostError when both steps fail. It carries the swap error and has the reopen failure as its cause, and its message tells the operator to restart. The demo reset lets that error through to DemoResetJob's tick, which logs it, and still logs and skips a failed swap whose reopen succeeded. A restore that hits it answers the same 500 as any other thrown restore error, with both failures in the server log. |
||
|
|
fd28ba7fd3 |
fix(demo): save the first baseline only after a real seed again
Moving the first baseline save out of the boot-time seed into DemoResetJob also changed when it happened. The seed saved it only on the boot that put the example trips in; a later boot returned early and saved nothing, so an instance with data but no baseline logged "No baseline found, skipping" on every hourly reset. The job instead saved one on any demo boot that found no travel-baseline.db, freezing whatever the database held at that moment, user edits included, as the target the hourly reset rolls back to. The seed now leaves a mark when it seeds the example trips, and the job saves the first baseline only when that mark is set and no baseline exists, which is the old trigger. As before, the save does not depend on the cron registrar being enabled. The demo mode wiki page says so. |
||
|
|
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. |
||
|
|
8ede0dab9e |
refactor(server): provide MaintenanceRepository through DI
MaintenanceRepository was built with new over an injected EntityManager in the readiness probe and in UserCleanupService, outside the container. MaintenanceModule now provides it, as a factory over the same context-resolving EntityManager Nest injects everywhere, and both inject it. The class itself stays free of Nest decorators, like the rest of src/db, so tests keep building one over their own EntityManager. No behaviour changes: the probe still pings and the erasure still runs inside the account deletion's transaction. |
||
|
|
0a4c6fbace |
refactor(db): open the database through a DatabaseLifecycle provider
db/database.ts opened the connection as a side effect of being imported, so the moment anything reached it the database was open. index.ts had to defer loading the app until a first-boot restore was done, and nothing in the container decided when the connection opened or closed. The module now opens the connection on first use and opens nothing at import. A connection closed on purpose (a restore, the shutdown) stays closed until it is reopened, as before. The new DatabaseLifecycle provider owns the decisions: buildApp() opens through it, which also binds the ORM to later swaps (the hook attachOrm used to register) and runs the schema bootstrap in the same order as before, and the shutdown closes through it. Its file property is the one resolved database path. The module keeps every export, so the test suites that replace it with vi.mock keep working; buildDbMock gains the new openDb. |
||
|
|
a0e1a39acc |
ci(server): ratchet the open shapes in every schema a DTO binds
contracts:open in shared only counts exports named *RequestSchema, but the name is a convention: 37 body DTOs wrap a schema called *BodySchema, *InputSchema or plain *Schema, and tags.dto.ts declares its schemas locally. pluginInstanceConfigUpdateSchema (a record of unknown behind PluginConfigDto), createAlbumLinkSchema with five open shapes and a dozen more were never counted, and a new open body under one of those names passed both checks. tests/unit/contracts/dto-open-shapes.test.ts walks the other way round. It finds every class that extends createZodDto under src/, imports it, and counts the open shapes in the schema the Zod pipe validates with shared's walk. Each class is held to scripts/dto-open-shapes-baseline.json, 61 DTOs with 101 open shapes today, and a new DTO may hold none. It fails closed: a createZodDto call that is not an exported class, two DTO classes with one name, an empty tree or an unreadable baseline stop it. It runs with the unit tests; npm run contracts:dto-open runs it on its own and --update only lowers the baseline. The five DTO classes declared inside roadtrip controllers are exported so the walk can reach their schemas. The selector test picks up prettier's wrapping of the cases added before. |
||
|
|
30bdcf061b |
chore(server): spell out the coverage pin and drop a dash from the redirect note
Wording only: the comment over the new coverage entries and the safeFetch doc comment it shares a change with. |
||
|
|
a86aa4e819 |
ci(server): refuse SQL text in repositories and seeders, move the gallery oracle to tests
The repository rule that keeps dialect SQL in sql-functions.ts only
matched raw() and the sql tag, so a SQL string handed straight to the
connection, em.getConnection().execute('INSERT OR IGNORE ...'), passed
unseen, and the seeders sat outside every repository rule. A new block
refuses a string or template whose text starts like a statement as the
first argument of execute, run, all or get in src/db/repositories and
src/db/seeders; a result mode such as qb.execute('run') stays legal.
The files that already hold such SQL are named one by one so the list
can only shrink: MaintenanceRepository (PRAGMA, VACUUM INTO),
DemoRepository and six of the seven seeders.
The selector groups are named once in eslint.config.mjs, and the
selector test now also checks the new block per path, its exceptions,
and that raw() stays forbidden in repositories.
journey-gallery-order.ts exported raw SQLite ORDER BY text from
src/nest that no production code read any more since the gallery reads
moved onto JourneyPhotosRepository. Two parity tests still compare the
Kysely rebuild against it, so it moves to tests/helpers as their oracle.
|
||
|
|
3fbe0418eb |
fix(server): bound every SSRF-guarded fetch in time and size
The ESLint rule from #2610 that requires a signal on outbound fetches only matched the platform fetch. The guarded wrappers hand it a spread init, which the rule trusts, so safeFetch(url, undefined, options) passed lint with no timeout, and the dispatcher left undici's five-minute default in place. Nothing capped the body either. Two callers did exactly that on hosts TREK does not control: the Synology thumbnail read (a user-configured NAS) and the place enrichment photo download (a URL out of third-party place data), both buffering the whole answer. The wrappers now carry defaults. A call without a signal waits 30 s for the response headers; the body stays under undici's idle timeout, so a proxied video that pauses with its player is not cut off. timeoutMs adds a deadline over the whole exchange and joins the caller's signal, once for the whole redirect chain. Every body is capped at 64 MB, refused up front when the declared length is larger and failed mid-read otherwise; callers pass a tighter maxBytes, or null where the body is streamed on (pipeAsset, the doc-sync transfers that count their own bytes, the Ollama pull). The two call sites get a signal and an 8 MB cap. The lint selectors now cover safeFetch, safeFetchFollow and safeFetchAdminConfigured as well, including an explicit undefined init, and are named once so the repository block restates the same list. A new test asks ESLint for the options it applies to a path and runs them over snippets, so a selector that stops matching fails a test instead of passing silently. |
||
|
|
28f6d30368 |
docs(notifications): why the reminder backfill counts days in UTC
The backfill in Migration20200101042100 marks reminders as sent by UTC
date('now'), while the reworked job counts days in the app TZ, which read
like a seam that sends twice or skips on upgrade day. It is not: the
backfill stands in for the job before it, which matched start_date
against UTC date('now') at 9 AM app time, so UTC is the clock those
reminders went out by, and with one run per UTC day nothing it marks
was left unsent. The only effect is its strict "<": the upgrade day's
own reminders stay unmarked because the boot may precede that day's
run, so a boot after it reminds such a trip once more. One extra
reminder at most and never a lost one, so the shipped migration stays
untouched and the reasoning now sits on the job.
The todo claim's docstring writes its dedup bound as SQL.
|
||
|
|
bb5cc8f795 |
fix(db): one account per email address, whatever its case
Login, password reset and the sign-up checks all match emails case-insensitively, but the only constraint was the column's own UNIQUE, which SQLite compares case-sensitively. Two accounts whose addresses differ only in case (legacy rows, two sign-ups at once) left a login to resolve to whichever row came first, and LOWER(email) lookups had no index to use. A migration adds a unique index on lower(email). An install that already holds such a pair must still start, so the migration then leaves the index out, logs how many addresses are shared and by which user ids (not the addresses), and records email_case_index_pending. Since a migration runs once, db/email-case-index.ts runs on every boot while that flag is set: it repeats the warning until the accounts are told apart, then creates the index and clears the flag. The app-level checks keep guarding meanwhile. A write that passes the up-front check and still loses the race is now refused by the database. The users repository turns that unique violation into UserIdentityTakenError, and sign-up, admin create and edit, and the profile update answer it with the same 409 their checks give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned the old behaviour and now pins the 409). The entity generator could not see an expression index (PRAGMA index_info names its column NULL), so PARITY-009c found a db index with no entity declaration. Rule 14 carries such an index into the entity as a named entry with its own statement; Users.entity.ts is regenerated. |
||
|
|
c04fca81ee |
fix(notifications): claim a reminder before sending it, give it back when sending fails
The trip and todo reminder ticks sent with `.catch(() => {})` and then
marked the reminder sent whatever happened. A send that threw (a DB error
resolving the recipients) or whose every channel failed (SMTP down, no
in-app) was recorded as delivered and never retried, without a log line.
And because the mark came after the send, two processes running the same
tick (a rolling update overlap, a stray container on the same volume)
both sent.
Each reminder is now claimed first with one conditional UPDATE through
its repository: reminder_sent_for is set only while it differs from the
start date (and the start date is still the one read), reminded_at only
while it is empty or older than the 20 hour dedup window. Only the
process whose update lands sends. If the send throws, or it attempted
in-app and channels and none went out, the claim is given back, the
failure is logged, and the next tick tries again until the trip starts
or the todo falls out of its window. A send nobody wanted (everyone
opted out) stays marked, since a retry would reach no one.
For that, NotificationsService.send() now reports what it attempted and
what was delivered; a channel that rejects or answers false counts as
not delivered. Existing callers ignore the result.
|
||
|
|
ff3615fdf0 |
feat(db): copy the database before a boot migrates it
Migrations only go forward, and since #2610 an older image refuses a database a newer one migrated. Going back after a bad update therefore needs a copy from before it, and the only copies were backups the admin takes by hand or auto-backup, which is off by default. The first boot on 4.4 migrates every 4.3.x install through the legacy baseline and the new migrations without either. Before applying pending migrations to a file database that holds anything, the boot now writes a VACUUM INTO copy next to it, named pre-migrate-<schema>-<time>.db (legacy-<schema_version> for an install the positional runner left, otherwise the last recorded migration), and keeps the newest TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP (3). The copy is written under a temporary name and renamed, so a crash never leaves a truncated file that looks good. If it cannot be written the boot refuses before anything is migrated; TREK_DB_PRE_MIGRATE_SNAPSHOT=false is the documented way past that. The newer-database refusal names the copy taken right before the newer release migrated past this one. A fresh install, a restart with nothing pending, an in-memory database and a restore's re-bootstrap (the archive is the copy) take none, and NODE_ENV=test leaves it off unless a suite sets the switch. |
||
|
|
a094f79efa |
feat(client): offer the new version when a release lands during a session
A tab kept open across a deploy ran the previous build until its next start. The server now names its version in the realtime welcome frame, which every open client reads again when it reconnects after the restart a deploy brings. When that version is not the build the page runs, a notice offers to reload. The reload goes through the same service worker handover a launch uses: it waits for the new worker to take over, or for a short timeout, and records the version only when the takeover happened. The notice reads the error boundary's texts for the same situation, so every language has it already. It is one component for both shells, sits above the offline pill, stays away offline and after a reload for that version that did not bring it, and can be closed for the session. |
||
|
|
588e483205 |
fix(calendar): VTIMEZONE spells out the offset changes of its span
Each zone's VTIMEZONE carried one fixed offset, so a client that reads it literally showed every event after a change to or from summer time an hour off. It now lists every offset change in the calendar years its events fall in, each as its own observance after the offset in force before the first one. A zone without changes, or a span with no readable date, keeps the single fixed offset. |
||
|
|
e0efa0314a |
feat(tours): tours read and write through the offline core
A tourRepo replaces the direct toursApi calls: reads fall back to a new Dexie tours table, and a tour saved or edited offline is written there and queued like a place, under one temporary id for the place and its facet. The queue moves both to the real id on replay and drops both when the server refuses the create. Trip sync caches each trip's tours while the addon is on, so a tour no longer reads as a plain place offline. The tour metrics move to shared so the offline copy shows the server's numbers, and useTourPlaceIds keeps one Set per change as intended. |
||
|
|
f7edd849ab |
fix(tours): one tour type catalogue, and no column nothing writes
tour_types now holds every key the shared contract accepts, all but hike disabled, and create and edit turn a disabled type away with a 400 instead of failing on the foreign key. tours.tour_group_id is dropped: nothing ever wrote or read it. difficulty and wanderer_ref stay for the Wanderer import. |
||
|
|
e6e270610c | fix(places): type the tour mark on the single-place row | ||
|
|
57df945318 | style: plain punctuation in the new comments and test names | ||
|
|
40bce5554d |
docs: the key check, the restore guard, the new probes, failed offline changes and reminders
Updates the wiki for the changes in this branch: TREK refuses an ENCRYPTION_KEY that disagrees with the key file, the rotation script covers the AI Parsing key and moves the file along, a restore refuses a newer backup and keeps the replaced database, the chart's startup and readiness probes, PORT and extraEnv, retrying or discarding failed offline changes, trip reminders catching up, and why a Portainer recreate used to keep reporting the old version. |
||
|
|
6b037e0fa0 |
refactor(tours): measure distances with the shared haversine
The Tours metrics carried their own copy of the great-circle formula next to nest/common/geo.ts, which exists so there is one. |
||
|
|
094a46a3cf |
fix(vacay): stop swallowing a failed delete as a holiday API error
One try around the holiday fetch and the deletes that follow it caught both, so a database error while removing vacation days that fall on a public holiday was logged as nothing and left the days standing. Only the fetch is tolerated now. Also drops two files long gone from the Sonar coverage exclusions. |
||
|
|
48f9e7d132 |
perf(db): index every foreign-key column
86 foreign-key columns had no index. SQLite does not add one by itself, so every cascading delete of a trip or a user scanned each such child table in full, inside the transaction, while every other request waited on the one connection. One migration adds them all, and the entities are regenerated to match. |
||
|
|
c60277c4d2 |
fix(tours): a tour another member creates is a tour on the phone too
The list read marked tour places with tour_place_id, but the single-place read behind place:created and place:updated did not, and the phone's places browser kept a second tour list of its own that no realtime event updated. A tour another member made, or one imported from GPX, showed as an ordinary place on the phone until the screen remounted, and every tour did offline. The single read carries the mark now, the browser asks the planner, and the tour id set is memoised instead of rebuilt on every render. |
||
|
|
72fbbd55d4 |
fix: a trashed note file leaves its note, quieter MCP sweep, bounded compose logs
A note's attachment moved to the trash from the file manager kept showing on the note; the hydration read now skips it, as the chat one already did. The MCP session sweep logged a line every minute while any session was open; it logs only when it cleaned something. The compose file caps the container log, and three test mocks that pointed at modules long gone are removed. |
||
|
|
76df44d8de |
fix(oauth): purge tokens whose refresh token expired a month ago
Every refresh writes a new oauth_tokens row and nothing ever deleted the old ones, so an MCP client refreshing hourly grew the table, and every backup with it, without end. A nightly job now deletes tokens whose refresh token expired more than 30 days ago, leaves first, and keeps any row a live chain still points at for replay detection. Also types the backup's per-entry store flag. |
||
|
|
a869269591 |
fix(deploy): follow PORT in every probe, wait for a slow boot, and probe the database
Changing PORT left the Dockerfile and compose healthchecks, the chart's containerPort, Service target and probes on 3000, so the container went unhealthy and Kubernetes restarted it in a loop. All of them follow PORT now. The chart gains a startup probe (ten minutes for a first boot, a long migration or a restore at boot, which liveness used to kill after about 105 s), a readiness probe on the new /api/health/ready that asks the database, extraEnv and extraEnvFrom for the settings the chart does not list, slots for the four secrets it had none for, and the DEFAULT_LANGUAGE and MCP_MAX_SESSION_PER_USER values it documented but never set. The Dockerfile no longer hides a failed npm ci behind a ';', pins gosu, and the language lists name all 27 locales. |
||
|
|
205759e3cf |
fix(scheduling): do not start a cron tick while the last one still runs
CronJob ran a tick on schedule even when the previous one had not finished, so an hourly auto-backup of a large library could overlap itself, and a rejected tick only reached cron's own console line. Jobs now wait for completion and log failures through the app logger. The backup also stores photos, videos and PDFs instead of deflating already-compressed files at level 9. |
||
|
|
7e8c9ac3a5 |
fix(server): give the last six outbound fetches a timeout, and lint for it
Overpass, the Wikipedia photo lookup (which holds one of five shared photo slots), the plugin download and registry, and the two API key checks had no signal, so a provider that took the connection and went quiet held the request for undici's 300 s default. ESLint now refuses a fetch() whose init carries no signal. |
||
|
|
a8fc93dfde |
fix(notifications): take today from the instance's TZ and catch up missed trip reminders
The reminder crons run at 09:00 in the instance's TZ but computed today in UTC, so east of Greenwich a trip reminder arrived a day late. A day the job did not run also lost that day's trip reminders for good: the match was 'starts exactly N days from today'. Trips now record the start date a reminder went out for, and the job sends every reminder that is due and not yet sent. The active-trip redirect and the upcoming reservations use the instance's date and clock as well. |
||
|
|
e0aedbb2c6 |
fix(trips): count trip dates in calendar days, the same on REST and MCP
Filling in a missing end date and keeping a trip's length when its start moved both shifted a local-time Date, so a trip crossing a DST change lost or gained a day. Both now use addIsoDays. The one-date-makes-a-week default moves from the REST controller into TripsService.create, so the MCP create_trip tool no longer stores a half-dated trip, and the trip and its days are written in one transaction. |
||
|
|
bfa512589c |
fix(db): baseline a database from the pre-ORM Tours branch correctly
That branch numbered its own steps after the upstream step it was cut from, so a database at schema_version 243 to 246 (or 216 to 218) with the Tours tables had four upstream migrations marked as run without ever running them. The baseline now cuts such a version back to the last upstream step, and the Tours migration adds the difficulty cap to a tours table an earlier branch stage created without it. |
||
|
|
63f7eab4bb |
fix(auth): match email addresses case-insensitively everywhere
Login and the OIDC fallback match emails case-insensitively, but the password reset lookup and the admin create and edit checks compared exactly. An account stored as Foo@x.com never got a reset mail, and an admin could create bob@x next to Bob@x, after which login picked whichever row came first. The signup collision check and the first-user count also move inside the signup transaction. |
||
|
|
e7ab0fc21e |
fix(users): delete a user who recorded a payment between two other members
budget_settlements.created_by_user_id has no ON DELETE action and the account cleanup never cleared it, so deleting such a user failed on the foreign key and rolled the whole deletion back. The cleanup now clears the recorder; the payment itself belongs to the two members and stays. |
||
|
|
71a0af02b0 |
fix(db): refuse a database or backup a newer TREK migrated, and keep a copy before a restore
Rolling an image back, or restoring a backup a newer release made, ran older code against a schema it does not know and failed later, far from the cause. Boot now refuses a database that records migrations this build does not ship, and the restore check refuses such a backup while the current database is still in place. A restore also keeps the database it replaces as data/pre-restore-<time>.db. |
||
|
|
99263fdbeb |
fix(security): refuse an ENCRYPTION_KEY that disagrees with the key file
An env value different from data/.encryption_key used to overwrite the file on boot, destroying the only copy of the key the stored secrets were encrypted with. The server now refuses to start and names the rotation script, which re-encrypts the AI Parsing addon key as well and moves the key file to the new key. Under test the suite's fixed key no longer overwrites the developer's key file. |
||
|
|
c6b6b83d38 |
fix(docker): take the version from the image, not from the container's env
Portainer's recreate and a copied run config carry the previous image's APP_VERSION into a container built from the new one, so the server kept announcing the old release and clients never handed over to the new bundle. The Dockerfile now writes the version into server/VERSION, which the server reads before APP_VERSION, and prerelease bundles take their version from the same build argument. |
||
|
|
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 | ||
|
|
562d8c41d2 | merge: update Tours branch to current dev | ||
|
|
c7793a6017 |
fix(email): link notification preferences to settings tab
- Make the “Manage preferences” text in notification emails clickable. - Link directly to `/settings?tab=notifications`. - Preserve the localized link text and existing email styling. - Add a regression test for the generated preferences URL. |
||
|
|
9e475f447a | feat(tours): add non-motorized day tour planning | ||
|
|
5669f8b860 | fix(places): e-mail check without a backtracking regex; explicit grouping in the no-proxy match | ||
|
|
62950aed24 | test(e2e): hand-made schemas follow the new columns; placing entries never blocks the refresh | ||
|
|
d694fc68f3 | chore: journey type fields for the test typecheck, prettier on the new shared schemas | ||
|
|
b6b4904664 | feat(bookings): cruises list their ports of call with arrival and departure (#1807) | ||
|
|
db4337287b | fix: mark deliberately unawaited promises with void (Sonar S9383) |