Commit Graph

245 Commits

Author SHA1 Message Date
Maurice d88dc17e8e ci(release): build the bump commit, add a minor tag, make the bump an explicit choice
docker.yml built and published whatever `main` pointed to when the build
and merge jobs started, not the commit it had just bumped and tagged, so
anything that landed in between shipped under a version that did not
contain it. build, merge and release-helm now check out the bump commit,
the way docker-dev.yml already pinned its prerelease builds. The push of
that commit fast-forwards main from the commit the run started on, so a
race fails the release instead of skipping over it.

Nothing tied a release to a green CI run. Both release workflows now
refuse a commit unless every ci-ok check run on it has finished green,
and docker.yml refuses to run on anything but main.

bump=auto fell back to a patch whenever no prerelease was in flight,
which would have published the ORM release, the largest change the
project has made, as 4.3.4. auto now only finalizes a prerelease above
the last stable tag and otherwise stops, asking for patch, minor or
major. The inputs are unchanged. A patch that adds migrations gets a
warning, not a refusal, since patch releases have carried them before.

Stable releases also publish :MAJOR.MINOR, so an install can take the
fixes of one minor line without being moved onto the next on day one.
The tag tables in the wiki list it and the prerelease tag, and the root
CLAUDE.md describes the release and prerelease flow.
2026-10-08 13:04:43 +02:00
Maurice 2f5d16ac74 build: pin the Node major in .nvmrc and declare the supported range
Nothing said which Node TREK runs on. The image is node:24, part of CI
ran 22 under comments saying 22 matched the server runtime, and the dev
guide said "22+". A bare-metal or dev install on an unsupported Node
installed without a word and then failed at runtime: isomorphic-dompurify
needs ^22.22.2 or ^24.15.0, the plugin jail's --permission flag is new
in 22.x.

.nvmrc now holds the major the Docker image runs, for nvm/fnm and for
every workflow to read. engines in the root and each workspace states
the range the runtime dependencies accept, the same one
isomorphic-dompurify declares. Without engine-strict npm only warns
outside it, so no existing install stops installing.
2026-10-08 13:04:43 +02:00
Maurice 48b1e879d2 fix(offline): an online edit waits behind an older queued write to the same entity
Holding later queued writes behind a parked one only ordered writes that
were in the queue. Online, the repos send an edit straight to the server, so
an edit made while an older write to the same visit, tour or driving settings
was backing off or parked landed first, and Try again (or the next flush)
then replayed the older write over it. Those writes carry no version token,
so nothing refused the stale replay.

The repos of visits, day clears, tours and driving settings now ask the
queue before an online write. While an older write to the same entity is
pending, in flight, parked or a conflict, the new write is queued too, with
the same optimistic Dexie write as offline, and a flush is started so it
goes out as soon as the one ahead of it does. When the driving settings left
the cache since the older write, they are read in first.

Places and packing items keep their direct online path: they send the base
token, so a stale replay is refused with a 409 and becomes a conflict. What
this does not cover, and the docs now say so: a collaborator's change to a
token-less entity is overwritten by the retried write, as by any replayed
offline write of those resources, and the planner's two time edits that call
the API directly are not held. The wording of the parking commit also named
budget items, which never go through the queue.
2026-10-08 13:02:58 +02:00
Maurice b61a55f037 fix(offline): archived trips on the device follow the eviction rules again
Keeping archived trips stopped the sync from wiping them, but left nothing
that ever took one off the device. The trip sync never refreshes an archived
trip, and the 7-day rule and the per-trip switch only looked at the active
list, so an archived trip stayed until Clear cache however long ago it ended.

The stale and switched-off rules now also run over the archived trips that
are stored here, with the same eviction as an active trip: its rows go, its
parked changes stay. The offline settings list the stored trips the server's
list leaves out, so an archived trip gets a switch the user can turn off;
before, the switches came from the active list only and could not reach it.
2026-10-08 13:02:58 +02:00
Maurice b4f705a049 fix(offline): a parked write holds back the later writes to the same entity
Parking a write the server keeps failing on unblocked its trip, so a later
queued edit of the same budget item, visit, tour or driving settings went
through while the older one waited. Try again then replayed the older
write last and it silently overwrote the newer one: only places and
packing items send a token the server could refuse it with. Before the
parking change a 5xx stopped the whole flush, so this order was never at
risk.

The flush now holds back every pending write whose entity has an older
write parked as failed or waiting as a conflict, and a write that parks
during the pass holds back the rest of that pass too. Other entities and
other trips keep syncing. Try again therefore replays the parked write
first and the held ones after it, Keep mine does the same for a conflict,
and Discard or Keep theirs flush right away so the held writes go out
without waiting for the next trigger. Writes queued against an offline
create that got parked now wait for it instead of failing as an
unresolved temp id.
2026-10-08 13:02:58 +02:00
Maurice 237f82c0b6 fix(offline): an archived trip is not a deleted one, and eviction keeps parked changes
The trip sync clears every cached trip the server no longer lists, but it
read only GET /trips, which leaves archived trips out. Every sync (login,
reconnect, the periodic run) therefore wiped every archived trip from the
device, pinned ones included, and with it their parked failed changes. A
trip now only counts as gone when it is missing from both the active and
the archived list, and when the archived list cannot be read nothing is
treated as gone that round.

clearTripData no longer deletes 'failed' rows. Stale eviction, a trip
switched off in the storage settings and a trip that is really gone all
go through it, and none of them is the user deciding to give up a change.
A parked change of a deleted trip, or one the user was removed from, now
stays in the queue: Settings > Offline counts it under failed changes and
offers Try again (the server answers 403/404, so it parks again) or
Discard. Dropping it silently would lose the only copy of an edit the
user may still want to look at or redo elsewhere, and keeping it costs
nothing more than a counter the user can clear in one tap.
2026-10-08 13:02:58 +02:00
Maurice 7cb4a09837 docs(wiki): say which images refuse a newer database on rollback
The rollback section promised that an older image refuses a migrated
database and names the pre-migrate copy. Only images from 4.4 on carry
that check. Going back to 4.3.x, the most common rollback today, gives
neither the refusal nor the pointer, so the copy has to be restored by
hand before the old image starts.
2026-10-08 13:02:58 +02:00
Maurice 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.
2026-10-08 13:02:58 +02:00
Maurice 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.
2026-10-08 13:02:57 +02:00
Maurice bee500880e fix(i18n): every language says one file and five files its own way
Finishes the plural work the previous wip commit started. t() picks a
count string's form by the language's own Intl.PluralRules, and every
locale now spells out the forms its language reaches with whole
numbers, about 160 count strings in all: one, few and many in Russian,
Ukrainian and Polish, one and few in Czech and Slovak, five forms in
Arabic, one in the others, none in Japanese, Korean, Chinese, Thai,
Vietnamese and Indonesian. Each language was translated and then
reviewed on its own. English no longer reads "1 files", "1 updates
available" or "photo(s)".

Count labels shown next to a number now take that number too: the
atlas and admin statistics, the trip cards, journey and shared trip
pills, packing templates, receipts, storage usage, the Studio export
note. A heading that shared its key with a counted label keeps a key of
its own (the boarding pass heading, the Bookings tab), so languages that
put a noun after a number in the singular keep a plural heading. The
plugin hosts chip passed its count through .replace() after t() and
never got a form; three toasts sent the count as text. Mail and push
texts for shared photos and suppressed storage failures pick their form
in code through pluralForm.

A whole number sent as text counts too, which is how in-app
notification params arrive, and the plural rule follows the language of
the strings on screen while another one still loads.

The parity check knows which forms each language needs and refuses
missing and impossible ones. It also reads keys that contain a colon
now: it skipped them, which hid 58 missing plugin permission texts in
Catalan and 35 in Vietnamese, both translated. The client parity test
covers every language of the registry instead of a hand-kept list of 19,
and the placeholder spec lets a form spell its number out only where
its category is that one number. Brazilian Portuguese error screens
were in Breton, and an Arabic dashboard label held Hebrew text.
2026-10-08 00:01:59 +00:00
Maurice 0126ef3401 fix(client): the version notice asks about a change during the session only
Three gaps in the notice from the previous commit, found in review.

A page served from the network and claimed later by its own build's
worker counted as already taken over, so Reload went to that worker,
got the old build again and spent the session's reload: no notice and
no new build until the next start. Only a worker that replaced the one
that served the page skips the handover now, as at launch.

A reload that came before the new worker could take over (a slow
precache, a failed update, an install gone redundant) also spent that
guard, so the launch behind it could no longer finish the handover with
its own reload. That reload leaves the guard alone now.

An install whose server never reports the bundle's version (a hand-set
APP_VERSION) showed the notice in every session, and Reload could not
clear it. The store keeps the first version a page hears of, from the
app config at launch, and the notice only asks when a later report
differs: a different build at launch is the launch handover's to fix.

The server's wire-envelope test pins the welcome frame's fields and now
expects the version beside the socket id. The wiki's update page says
what an open tab shows after an update.
2026-10-08 00:01:33 +00:00
Maurice 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.
2026-10-07 21:30:45 +02:00
Maurice 57df945318 style: plain punctuation in the new comments and test names 2026-10-07 17:12:26 +02:00
Maurice 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.
2026-10-07 17:11:56 +02:00
jubnl 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 (23c292f2f). Two new e2e tests pin
the non-numeric-id 404 at the gate.

* test(server): plan 4 — full-key parity tests for ShareTokensRepository (3h L4)

3h L4's carry: ShareTokensRepository (SH1-SH6/SH17/UC6) had zero tests.
17 cases pinning every read against the legacy statement run raw on the
same connection — findRawByTrip/findTokenByTrip with every nullable
column both null and set, findValidByToken/findTripAndShareMapByToken/
findTripIdByToken's shared token+expiry predicate (valid, future-expiry,
past-expiry, unknown token), plus insertNew/updateFlagsByTrip/deleteByTrip/
deleteByCreator's write correctness (deleteByCreator's cross-trip erasure
kept distinct from deleteByTrip's single-trip scope, per the repository's
own docstring). The listPublicForShare family (the other half of 3h L4)
is a separate, larger carry — not in this file.

* test(server): plan 4 — full-key parity for Reservations share/public-api reads (3d carry)

Task 8b-2 item 1 (partial): the 8 share.service.ts/public-api.service.ts
reads on ReservationsRepository flagged by the 3d Task 7 review as having
no repository-level toEqual(<legacy raw>) coverage (listEndpointsForShare,
listDayPositionsForShare, listPublicForShare, listPublicAccommodationsForShare,
listScheduledForPublicApi, listUnscheduledForPublicApi,
listAccommodationsForPublicApi, listUnplannedPlacesForPublicApi). 12 new
cases against the legacy statement run raw on the same connection.

* test(server): plan 4 — concurrency pins for boundaries-upsert and token-mint races (3d carry)

Task 8b-2 item 2: R7's two remaining un-transacted-statement races flagged
by the 3d Task 7 review (L4) but never pinned — RoadtripDayBoundariesService
.save's belongs-check-then-upsert, and FeedsService.generateTripToken's
read-then-write. Both tests pin today's actual outcome (an atomic ON
CONFLICT upsert survives cleanly; the token mint has a last-write-wins
loser) with the same concurrent-request-shape Promise.all/allSettled
pattern roadtrip.service.test.ts's existing R7 vias pin uses. Not a fix.

* test(server): plan 4 — full-key parity for Days/DayAssignments/Places roadtrip reads (3d carry)

Task 8b-2 item 1 (remainder): the 3 non-share/public-api reads from the 3d
Task 7 review's missing-parity list — DaysRepository.listPlanDays (RPL1),
DayAssignmentsRepository.listRoadtripVisits (RPL2, the correlated-subquery
LEFT JOIN), and PlacesRepository.isTrackInTrip (RT13, the review's
"findTrackInTrip" — the actual method name). Completes the 11-file carry
together with 434113937's 8 share/public-api reads.

* test(server): plan 4 — dedupe overlapping listPlanDays/isTrackInTrip parity blocks

235f03665 landed two versions of the same DaysRepository.listPlanDays and
PlacesRepository.isTrackInTrip parity coverage side by side (a merge
artifact from concurrent work on the same carry item). Keeps one block per
method, drops the duplicate describe/it blocks and their colliding test ids.

* test(server): plan 4 — budget calc suite onto real repositories (3e carry)

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync/webhook secrets (3h L3)

Task 8b-2 item 4: the three credential columns the 3h Task 7 review flagged
as having no at-rest pin — users.airtrail_api_key (AirtrailService#saveSettings),
document_connections.secrets (DocSyncConfigService's whole-secret-map
column), and trip_document_links.webhook_secret (createLink's per-binding
mint). Each test saves through the real service, reads the raw column via
better-sqlite3, asserts it is enc:v1:-prefixed ciphertext that never
contains the plaintext, and asserts the service's own decrypt path rounds
it back to the original value.

* test(server): plan 4 — at-rest encryption pins for AirTrail/doc-sync secrets (3h L3 carry)

Task 8b-2 item 4: the 3h Task 7 review's L3 finding ("users.airtrail_api_key
has no at-rest pin", "document_connections.secrets ... has no enc:v1: check",
"trip_document_links.webhook_secret - no at-rest pin"). Three targets, each
proven by reading the raw column and asserting the stored value is
enc:v1:-prefixed ciphertext that round-trips, never the plaintext:
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow at the
repository layer, AirtrailService.saveSettings/getAirtrailCredentials at the
service layer, and DocSyncConfigService's connection secrets map plus
createLink's webhook secret.

* test(server): plan 4 — drop duplicate AirTrail encryption repository test

Task 8b-2 item 4 follow-up: 6965869dd already covers
UsersRepository.setAirtrailSettingsWithKey/getAirtrailConnRow's at-rest
ciphertext at the service layer (airtrail.service.test.ts's
AIRTRAIL-SVC-ENC-001/002) plus the doc-sync secrets/webhook pins
(DOCSYNC-ENC-001/002); this repository-level file duplicated the same
AirTrail assertion a second time.

* test(server): plan 4 — atlas read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (atlas half): full-key toEqual(<legacy raw>) coverage for
the atlas read models the 3f Task 7 review's L6 finding listed as missing
(minus AT1/TRAVELER_OWNS-consumer, already landed) — AT2 (Places.
listForTripIds), AT3/AT28 (PlaceRegions.listCountryCodesForPlaceIds/
listForPlaceIds), AT5/AT10/AT43 (VisitedCountries.listCodesForUser/
listForUser), AT21/AT24/AT25 (VisitedRegions.listForUser/
listRegionCodesForCountry/findCountryCode), AT22 (HiddenRegions.listForUser),
AT23/AT40/AT44 (PlaceRegions.listDistinctRegionCodesForCountryAndPlaces/
countPlacesByCountryForTrip/listVisitedCountryCodesForUser), AT30/33/34
(BucketList.listForUser/findById/findForUser), AT41 (Places.
listAddressesForUser), AT42 (Trips.countTripsAndDaysForUser). New standalone
files for HiddenRegions/VisitedCountries/VisitedRegions/PlaceRegions, which
had none. AT6/45/46 (the TRAVELER_OWNS endpoint reads) are proven on ids
only by the existing consumer test — full-row parity for those three is
NOT done here (carried forward, same as the review's own open item).

* test(server): plan 4 — vacay read-model parity remainder (3f L6 carry)

Task 8b-2 item 3 (vacay half): full-key toEqual(<legacy raw>) coverage for
the vacay read models the 3f Task 7 review's L6 finding listed as missing
(minus VC81/82, already landed) — VacayPlans.findByOwner/findById (the
getPlanData/getStats composite's own plan read), VacayYears.listForPlan
(listYears), VacayCompanyHolidays.listForPlan (the company-holidays list),
VacayHolidayCalendars.listForPlan/findById (holiday-calendar reads),
VacayUserSettings.findForUser (user year settings), VacayUserYears.
findForYear (getStats rows, including the NULL vacation_days case the
review named explicitly), and VacayPlanMembers.listAvailableForFusion/
listPendingForPlan/listPendingForUser (the fusion and share pickers). Seven
new standalone files — only VacayShares had a repository-level test before
this. getSharedCalendars itself is a service-level composite over these
same reads plus VacayCompanyHolidays.listDatesForRange (already exercised
elsewhere); its own dedicated test is NOT added here.

* test(server): plan 4 — full-key parity for PackingItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH13 read. Seeds Common items with every nullable
column both null and set, plus Personal and Shared-with-people items on
both sides of the is_private/owner/recipient predicate, and proves only
the Common tier surfaces.

* test(server): plan 4 — full-key parity for BudgetItemsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH14 read. Seeds two items — every nullable column
set and every nullable column null — plus a foreign-trip item, and proves
the ORDER BY category ASC shape (unlike BG72's listAllForTrip, which has
none).

* test(server): plan 4 — full-key parity for CollabMessagesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH16 read. Seeds a root message, a reply (reply_to
set), a deleted message (must be excluded) and a foreign-trip message, and
proves the narrower username/avatar-only join shape (no reply_text/
reply_username, unlike joinedQuery).

* test(server): plan 4 — full-key parity for PlacesRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the share.service.ts SH12 read. Seeds a categorised place with every
nullable column set and an uncategorised place with every nullable column
null, plus a foreign-trip place, and proves the named 20-column allow-list
never leaks an owner-only column (reservation_status, google_place_id,
…).

* test(server): plan 4 — full-key parity for DayAssignmentsRepository.listPublicForShare (3h L4)

Task 8b-4a: no repository-level toEqual(<legacy raw>) coverage existed for
the SH9 read. Seeds assignments across multiple days (one excluded day_id
never leaks in), every nullable place/assignment column both null and
set, and proves the six owner-only columns SharePublicAssignmentRow omits
(google_place_id, google_ftid, osm_id, amap_poi_id, stop_type,
fill_percent) never appear on the row at all.

* test(server): plan 4 — collapse remaining test harnesses; triage legacy migration tests

* test(server): plan 4 — todo controller test expects the gate-parsed item id (U6 follow-up)

* fix(server): add the missing numbered migration for the booked-night reseat step (#242)

reseatBookedNights (legacy step 242) was never invoked from any shipped
MikroORM migration -- only from the retired db/migrations.ts array --
so an install upgrading through the numbered chain never got the
one-time reorder. Adds Migration20200101040300, which calls the frozen
function through the native better-sqlite3 handle so it runs inside
the migration's own transaction.

orm-driver.ts's BoundSqliteConnection now also captures that handle
onto the field SqliteConnection.getNativeClient() reads, which its
createKyselyDialect() override previously left unset -- needed for
this migration (and any future one) to reach it in production, not
just under the bare test driver.

RESEAT-009 moves off the legacy createTables+runMigrations builder
onto the real Migrator via tests/helpers/migration-step.ts, plus a
new fresh-install guard (RESEAT-011).

* test(server): plan 4 — swap 4 of 5 straggler nest test harnesses off legacy schema builder

day-notes.service.test.ts, public-api-scopes.test.ts,
registration-invites.service.test.ts, trip-invite.service.test.ts onto
createSnapshotTestDb() via the async vi.mock idiom (5a/5b/5c precedent),
dropping their own hand-rolled createTables()+runMigrations() harness.
trip-invite.service.test.ts and registration-invites.service.test.ts
also drop their now-redundant DatabaseService wrapper (its .connection
was always just the same test db handle). expect( assertions unchanged.

user-cleanup.service.test.ts held back: its constructor call was
updated to match UserCleanupService's new EntityManager-based shape,
but deleteUserCompletely() now deadlocks against a real UnitOfWork
(reproduced in isolation, independent of this test file -- see the
report), which looks like a genuine bug in erasePluginUserData's new
MaintenanceRepository(em) call versus UnitOfWork.transactional(), not
a test-harness problem.

* test(server): plan 4 — move auto-backup.test.ts and demo-reset-path.test.ts off createTestDb()

Both build a schema db while 'fs'/'node:fs' are mocked (wholesale in
auto-backup.test.ts, selectively spied in demo-reset-path.test.ts) for
their own archiver/filesystem assertions, which broke
createSnapshotTestDb()'s read of the schema snapshot through
schema-snapshot.ts's own fs import.

auto-backup.test.ts: fs is mocked wholesale, so the snapshot db is now
built via a small helper that points fsMock's three calls at the real
filesystem (fetched through vi.importActual, bypassing the mock) just
long enough to build the db, before the test installs its own
existsSync stub -- never touching schema-snapshot.ts itself.

demo-reset-path.test.ts: fs.existsSync is selectively spied to answer
only for the baseline path, so the db is now built before that spy is
installed instead of after.

* test(server): plan 4 — retire the legacy-runner-only migration hygiene checks

migration-hygiene.test.ts deleted outright: every block in it (the
destructive-op allowlist, the empty-catch guard, the full-chain smoke)
scans or runs the legacy db/migrations.ts array being deleted this
plan, never the numbered src/db/migrations/*.ts chain, so there is
nothing in it to carry forward.

leg-mode-incoming.test.ts: deleted its first describe block (the
legacy-runner column-add + replay-safety smoke, also against
db/migrations.ts); the read-path parity block, already on the modern
createSnapshotTestDb() harness, is untouched.

* chore(server): delete the legacy schema/migration/seed builders

MikroORM's migrations are the only schema source now, including for
tests: src/db/schema.ts, src/db/migrations.ts and src/db/seeds.ts are
gone, along with tests/unit/db/schema-parity.test.ts (the parity gate
that guarded them, already red on 8a's cache_key NOT NULL) and
createTestDb() + its now-dead imports out of tests/helpers/test-db.ts
(its last two callers moved onto createSnapshotTestDb() this plan).
Their three entries drop from eslint.config.mjs's better-sqlite3
allow-list -- the files they exempted no longer exist.

Ruling 7's frozen files (migration-utils.ts, reseat-booked-nights.ts,
document-provider-seed.ts) and every numbered migration in
src/db/migrations/ are untouched.

Precondition grep (server/tests + server/src, from the task brief) is
now empty:

  grep -rlE "from ['\"].*db/schema['\"]|from ['\"].*db/migrations['\"]|from ['\"].*db/seeds['\"]" server/tests server/src --include="*.ts"

* docs: plan 4 — the repository layer is the only data access path

DatabaseService/DatabaseModule are gone; queries go through repositories
(server/src/db/repositories/) and UnitOfWork (server/src/nest/database/
unit-of-work.ts) for transactions. MikroORM's migrations are the only
schema source, including for tests — the hand-kept schema.ts/migrations.ts/
seeds.ts test builders and their parity test are deleted. Documents the
db/database.ts (connection only) / db/orm-driver.ts (bound driver) /
UnitOfWork / repository shape, withRequestContext for non-HTTP entrypoints,
the better-sqlite3 ESLint restriction, and the entity generator (gen:entities/
check:entities) as the schema-parity guard. Corrects two stale nest/README.md
mentions (realtime.gateway.ts's now-repository constructor, the BE-Phase 1
trip-access bullet) and marks the legacy src/services/ migration recipe as a
historical changelog rather than current guidance.

* refactor(server): plan 4 — drop the 13 vestigial DatabaseService params/getters

Removes the now-dead DatabaseService injection (or private get db() getter)
from addons.service.ts, trips.service.ts, trip-invite.mcp.ts,
place-photo-cache.service.ts, trek-photo-registration.service.ts,
categories.mcp.ts, journey-domain.service.ts, plugins.service.ts,
plugin-runtime.service.ts, plugin-mcp-tools.service.ts, places.service.ts
and packing.service.ts — every one of them was fully repository-backed
already (Task 2/3's facade inline sweep), just still carrying the param.

PackingService's TripMembersRepository is required now (the
this.db.rosterUserIds fallback is gone with it) and PluginRuntimeService's
uow is no longer @Optional() — both moved ahead of the optional
registry?/hostFactory? params, since TypeScript refuses a required
parameter after an optional one; install/discovery.ts's DiscoveryRepos.uow
is required to match, and its upsert() always runs the descriptor
refresh inside one transaction instead of falling back to an
un-transacted sequence.

user-cleanup.service.ts's UC1 (the one genuinely live raw site, Plan 3b's
"stays raw" plugin-table trio) moves off DatabaseService onto a new
MaintenanceRepository.deletePluginUserData method — the rule-4-permitted
connection.execute() home, built from a directly-injected EntityManager.
Threads the active UnitOfWork.transactional's transaction context through
execute()'s ctx param: without it, a raw connection.execute() call issued
from inside deleteUserCompletely's open transaction contends for the same
single-connection checkout the transaction already holds and deadlocks
(never released, since the transaction is itself awaiting the call).
tests/unit/db/transaction-yield.test.ts's probe B already pinned this
exact hazard class for a raw better-sqlite3 statement; this is the same
mechanism reached through the ORM's connection pool instead.

nest/photos/trek-photos.repository.ts (the Plan 4 Task 8a re-export shim)
is deleted, with every importer repointed to trek-photo-registration.service
directly. The ~7 files that only imported the TripAccess/PlaceWithTags
type re-export from database.service.ts now import them from
Trips.repository.ts/Places.repository.ts directly, the actual single source.

* refactor(server): plan 4 — delete DatabaseService, DatabaseModule and database.tokens.ts

Grep gate confirmed zero src/ importers of DatabaseService left outside
these three files (Task 1/2/3's earlier conversions + this task's own
vestigial-param sweep). Deletes database.service.ts, database.module.ts
and database.tokens.ts, drops DatabaseModule from app.module.ts's imports
and the five other modules that still listed it
(accommodations/calendar/doc-sync/feeds/trip-members) — none of them had
a live DatabaseService injection left, so the import was already dead.
OrmModule is the module that survives DatabaseModule's deletion, per the
Phase 0 ledger's own ruling; untouched.

db/database.ts's `db` Proxy export itself stays, deliberately: ~190 test
files' vi.mock('…/db/database', …) factories return a { db: <mock
handle>, … } shape, and their own `import { db as testDb } from
'…/db/database'` is typed against this file's real exports regardless of
the runtime mock — vi.mock swaps the module at runtime, but tsc resolves
the import statically. Deleting the export would fail typecheck:tests
across the whole suite for a test-harness idiom this task's file set
doesn't own (Track B's territory). src/ has zero remaining importers, so
the ESLint no-restricted-imports gate this task also shrinks still holds
this file to its permanent allow-list entry either way.

* test(server): plan 4 — repoint the test suite off the deleted DatabaseService/DatabaseModule

Mechanical follow-through of the previous two commits across ~110 test
files:

- The 41 tests/e2e/*.e2e.test.ts suites composed DatabaseModule into their
  partial Test.createTestingModule graphs; the import and the array entry
  are dropped (nothing in any composed domain module injected
  DatabaseService any more, so the entry was already dead weight).
- The shared test helpers (mcp-test-controllers.ts, plugin-host.ts,
  accommodations-service.ts, notifications.ts, test-addons.ts,
  test-uow.ts) each held a DatabaseService/DatabaseService-shaped param
  used only for its raw better-sqlite3 handle; every one now takes that
  handle directly, and every constructor call site the helpers make
  (PackingService, PlacesService, JourneyDomainService,
  PlacePhotoCacheService, TrekPhotoRegistrationService, TripsService,
  UserCleanupService, AddonsService, PluginsService, PluginRuntimeService)
  drops the removed param and, where PluginRuntimeService's uow is
  involved, reorders it ahead of the optional registry?/hostFactory?
  params to match the real constructor.
- ~60 further unit/integration test files hand-constructed one or more of
  those same services directly; same fix, file by file. Four of them
  (memories-access.service.test.ts, places.directions.test.ts,
  places.service.test.ts, unified-memories.service.test.ts) also drop a
  vi.spyOn(DatabaseService.prototype | instance, 'canAccessTrip' | 'isOwner'
  | 'rosterUserIds' | 'getPlaceWithTags') spy left over from when those
  methods delegated through DatabaseService — dead now that the services
  read the owning repository directly.
- database-service.test.ts and database-service.delegation.test.ts tested
  DatabaseService directly and are deleted with it.
- tests/unit/db/transaction-yield.test.ts's probe B, which pinned a raw
  DatabaseService statement's dirty-read behaviour inside an open
  transaction (rule 24), now issues the same raw better-sqlite3
  statement directly against the shared test handle — the exact shape
  DatabaseService.get/run used to wrap (this.conn.prepare(sql).get/run(),
  bypassing MikroORM/Kysely entirely) — so the hazard it documents is
  unchanged. Verified still green.

No expect(...) assertion changed in any file; only construction/import
plumbing. typecheck, typecheck:tests and the full touched-file run
(101 files / 2668 tests) are green, plus the compiled boot
(bootstrap.test.ts + boot-sweeps-request-context.test.ts) and one real
e2e boot file (trips.e2e.test.ts).

tests/unit/nest/user-cleanup.service.test.ts is deliberately NOT in this
commit — see the next one.

* test(server): plan 4 — finish user-cleanup.service.test.ts's harness swap; regression-test the UC1 transactional deadlock fix

This file arrived already modified and unstaged in the shared tree —
Task 6 (the parallel legacy-schema-deletion task) had swapped its DB
harness onto createSnapshotTestDb() and updated the UserCleanupService
construction to this task's new EntityManager-first shape while landing
its own commits. Finished here: USER-CLEANUP-005 ("survives a slim
schema without the plugin tables") passed a fake `{} as unknown as
EntityManager`, which doesn't reach the real UC1 path any more (the
previous raw DatabaseService.run swallowed everything in a try/catch
regardless); it now builds a real EntityManager bound to the slim
handle via sharedTestOrm(slim), so the per-statement query against the
missing plugin_user_config/plugin_oauth_tokens/plugin_oauth_state
tables is what's actually being proven caught, not a construction-time
throw.

USER-CLEANUP-006 through -010 (deleteUserCompletely, all of them
exercising UC1 inside the real uow.transactional wrap) are the
regression test for the previous commit's deadlock fix in
user-cleanup.service.ts/MaintenanceRepository.ts: before that fix, every
one of these hung and timed out. Green now, verified directly.

* chore(server): plan 4 — shrink the better-sqlite3 ESLint allow-list

Per ruling 6: database.service.ts's entry comes off (the file is
deleted). The five plugin entries (plugin-route-normalize.ts,
plugin-host-state.ts, install/discovery.ts, settings-defaults.ts,
signature-status.ts) come off too — re-verified none of the five import
better-sqlite3 or DatabaseService any more (3j's own conversion). The
src/demo/** blanket entry was already gone (3i's own task landed it).

reseat-booked-nights.ts and document-provider-seed.ts stay, with the
`// TODO(plan 4)` comment corrected: they're frozen forever (ruling 7),
invoked from inside numbered, already-shipped MikroORM migrations, never
Plan 4's or any future plan's to touch.

Permanent allow-list after this commit: src/db/database.ts,
src/db/orm-driver.ts, src/db/durability.ts (the driver homes),
src/nest/plugins/host/plugin-data.service.ts (3j's R-out-of-scope — a
plugin's own sqlite file is never TREK's schema), src/nest/backup/backup.impl.ts
(3i's R1, exactly the untrusted-upload-file probe), src/db/reseat-booked-nights.ts
and src/db/document-provider-seed.ts (frozen forever). src/db/{schema,migrations,seeds}.ts
entries were already removed by Task 6 when it deleted those files.

0 new lint errors; warning count unchanged at 2123 (well under the 2247
lean-gate ceiling) — every file the shrink touches was already fully
converted.

* refactor(server): plan 4 — U6 gate-level id parsing for budget item/settlement ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:settlementId/:userId route param in BudgetController is parsed ONCE at
the gate with toRowId, matching the legacy affinity-seam "not found" outcome
for a malformed id on each route (404 'Budget item not found'/'Settlement
not found', except toggleMemberPaid — the one legacy handler that never
404'd an unknown item/member either, so a malformed id there skips the write
and keeps the same 200 { member } + Number(id)/Number(userId) broadcast
shape it always had).

The parsed numbers thread down through BudgetService's update/updateMembers/
setPayers/toggleMemberPaid/remove/updateSettlement/deleteSettlement (and
their updateBudgetItem/deleteBudgetItem/setItemPayers/getBudgetItem/
applySettlementUpdate/getSettlement internals) into BudgetItemsRepository's
getCurrency/findInTrip/existsInTrip/findForDelete/findById/update/deleteById
and BudgetSettlementsRepository's findWithUsers/findGuard/update/deleteById,
all narrowed from number | string to number; trip_id stays loose, a
separate, already-accepted carry. costs.rpc.ts's num()-derived itemId no
longer needs its String() wrapper on the update/delete calls; budget.mcp.ts
was already Zod-typed to number throughout. Two new e2e tests pin the
non-numeric-id 404 at the gate; controller/service unit tests cover the
narrowed call args and the toggleMemberPaid malformed-id parity case.

* refactor(server): plan 4 — U6 gate-level id parsing for packing item/bag/template ids

Rule 21's 3e carry, same discipline 218f75d0f applied to todo: every
:id/:bagId/:templateId/:userId route param in PackingController is parsed
ONCE at the gate with toRowId, matching the legacy affinity-seam "not found"
outcome for a malformed id on the routes with a pre-ORM Express precedent
(update/remove -> 'Item not found', updateBag/deleteBag/setBagMembers ->
'Bag not found', applyTemplate -> 'Template not found or empty').

#858's sharing/contributors routes (setSharing/clone/addContributor/
removeContributor) are native Nest code with no pre-ORM precedent (landed in
7eabf6066, after the migration) — a malformed id there just 404s the same
way an unknown one already does. removeContributor's :userId used to reach
contributorsRepo.deleteOne through a bare Number.parseInt(), the exact
NaN-into-SQL trap row-id.ts documents; it now parses through toRowId too.

The parsed numbers thread down through PackingService's updateItem/
setItemSharing/addContributor/removeContributor/cloneItem/deleteItem/
getItemPrivacy/updateBag/deleteBag/setBagMembers/applyTemplate, all narrowed
from number | string to number; tripId stays loose, a separate,
already-accepted carry. PackingItemsRepository's getPrivacy/findVisibleInTrip/
update/updateSharing/delete/findById, PackingBagsRepository's findInTrip/
update/findWithAssignee/delete and PackingTemplateItemsRepository.listForApply
narrow the same way. packing.rpc.ts's num()-derived itemId/bagId no longer
need their String() wrappers on the update/delete/updateBag/deleteBag/
setBagMembers calls; packing.mcp.ts was already Zod-typed to number
throughout. Two new e2e tests pin the non-numeric-id 404 at the gate;
controller/rpc unit tests cover the narrowed call args and the
removeContributor malformed-userId parity case.

AdminPackingTemplatesController (/api/admin/packing-templates) is out of
scope — a separate admin-gated surface with its own string-typed template
CRUD, not part of this trip-scoped controller's :id chain.

* test(server): plan 4 review — three pins catch up with the gate-parsed ids and the cache_key NOT NULL (M1–M3)

* fix(server): boot baselines legacy-runner installs before running MikroORM migrations (B1)

An install the retired positional runner migrated has a schema_version row
and no mikro_orm_migrations rows, so its first boot on this branch replayed
all 244 migrations over an already-migrated schema: eight refuse the boot,
and the replay-safe ones overwrite user data (step 26 rewrites every
day_assignments.assignment_time).

runSchemaBootstrap now records the baseline plus every migration whose
"Legacy migration step N" docstring is <= schema_version as executed, in one
transaction through the migrator's own storage, before the ordinary run
applies the rest. The step map is derived from the discovered migrations'
source, never hand-kept, and anything unmappable (a newer or malformed
schema_version, a duplicated, missing or out-of-order step) refuses the boot.

* test(server): boot the app on three legacy-runner fixtures to pin the B1 baseline

SQL dumps of databases the retired runner itself built: schema_version 205
(main at fa406b708), 242 (417d0445b, the runner's last commit) and 82
(v2.9.14, so seven of the eight non-replay-safe migrations still lie ahead
and run for the first time). Each gets canary rows: user-set assignment
times and a booked night.

buildApp() on each asserts the baseline and the applied rest in order, the
assignment times untouched, the booked-night reseat run exactly once on 205
and 82 and not at all on 242, schema_version kept, no half-built *_new
table, a schema identical to a fresh install's, and an idempotent re-run.

* refactor(server): demo-reset reads the live path off getRawConnection, not the db Proxy (m1)

demo-reset.ts was the one src/ consumer of the db Proxy left, reached
through a runtime require that a grep on `from` misses. It now imports
closeDb/getRawConnection/reinitialize statically, the way backup.impl.ts
does, so the Proxy genuinely has no src/ importer (the claim in database.ts
and server/CLAUDE.md now holds). The path test mocks the module with vi.mock
instead of teaching Node's require loader to answer for a .ts file.

* test(server): renumber the duplicate ASSIGNREPO/PLACEREPO test ids Plan 4 introduced (m3)

ASSIGNREPO-031/032 (the legacy-statement parity pair) become 034/035, and
the later PLACEREPO-030..038 block becomes 053..061, the next free ids, so
each id names one test again. No test body changes.

* refactor(server): derive the two restated public row types instead of copying their columns (m4)

JourneyPublicEntryPhotoRow restated JourneyPublicGalleryRow's nineteen
columns with only the parent key changed; it is now that row with
journey_id swapped for entry_id. PublicApiAssignedPlaceRow and its Kysely
table shape restated PlaceRow's columns; both now Pick them off PlaceRow.
Types only. The review's CPD method over Plan 4's new src lines drops from
about 3.4% to 0.64%.

* test(server): full-key parity for TripsRepository.findPublicForShare and VacaySharesRepository.listAvailableForShare (m5)

Neither read had any test reference. Each is now pinned with
toEqual(<legacy statement run raw on the same connection>), the
listPublicForShare family shape: SH7 on a filled and a NULL-heavy trip plus
a missing id (TRIPREPO-067/068), and VC90 over one world exercising every
exclusion (caller, guest, existing share, plan owner, accepted member) and
a missing plan.

* fix(server): the legacy baseline runs createTables() instead of marking it, atomically with the run (N1)

The legacy runner ran the running release's createTables() on every boot
before its pending steps, and about 30 tables exist only there. Marking the
baseline migration as executed left installs older than those tables (below
legacy step 68) failing with "no such table: photo_provider_fields" and, since
the rows were committed before the run, stuck half-baselined for every later
boot.

Only steps 1..N are recorded now, so the baseline's IF NOT EXISTS DDL runs
first, as the legacy boot did. The rows are logged inside the same
transaction as migrator.up({ transaction }), so a failed first boot records
nothing and the next boot baselines again.

A v66 fixture (the runner at 5be2e9b26) boots: 66 recorded, 178 applied,
canaries kept. Its two schema differences from a fresh install
(users.immich_access_token, reservations.accommodation_id INTEGER) come
from the legacy chain itself and are pinned as carried.

* fix(deps): sync the lockfiles with the bumped manifests and follow lucide's icon renames

feat/orm raised react to ^19.3.0 and several plugin-sdk dependencies without
regenerating the lockfiles, so npm ci failed on every CI job. The root
overrides now pin react 19.3.0, both lockfiles are regenerated with npm 11,
and the tests that select lucide icons by their old class names use the
current ones. Two tests cover the Immich prefs reads and the boot restore
branch that the coverage thresholds were missing.

* ci: give the server lint step an 8 GB heap for the type-aware rules

* test(server): keep the no-promise-as-value rule tester out of single-run mode on CI

With CI=true typescript-eslint infers single-run mode, builds the program
from the fixture file on disk and ignores the code each case passes in, so
all 24 invalid cases reported nothing on the runner. Both testers now set
disallowAutomaticSingleRunInference.

* refactor(server): name the new migrations after the first seven words of their purpose, like the rest

* fix(server): keep tsx a production dependency so image key rotation can load it

* test(server): re-pin the repositories branch floor at the measured 89.83%

* test(planner): keep the sidebar fixtures on past dates so the jump to today never fires in jsdom

* test(server): keep console output for failing tests only and teach Nest's logger LOG_LEVEL

* test(client): keep console output for failing tests only and answer the health probe

* test(client): drop the health handler, it races the admin page's first load

* chore(sonar): assert explicitly in two e2e cases, Number.NaN, and ignore S2187 on the legacy-upgrade fixtures

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-10-06 19:27:30 +02:00
EvKoh b5514024fa docs(wiki): describe the map's places filters
Map-Features gets a "Filtering the places on the map" section: the markers
follow the places list's Show, category and rating filters, and on a phone
the Filters button of the plan map opens the same choices in a sheet.
Places-and-Search says the rating floor narrows the map markers too.
2026-10-04 16:20:16 +02:00
Maurice 05b53d024e docs(wiki): bring every page up to the next release; admin settings, user defaults and notifications get pages of their own 2026-10-01 19:40:22 +02:00
Maurice 88afdb986c feat(mcp): upload_trip_file tool for adding documents to a trip (#1566) 2026-10-01 12:39:58 +02:00
Maurice 1c34a74498 feat(calendar): one feed event per leg of a connecting flight or train
A flight or train with several legs comes out of the calendar feed as one event per leg, titled with its route and carrying the leg's own departure, arrival and segment reference, so the layover shows as the gap between them. A booking with a leg missing its departure time stays one event, as before. (#2389)
2026-10-01 12:32:42 +02:00
Maurice 5138358d7b feat(net): the SSRF-guarded requests go through HTTP_PROXY / HTTPS_PROXY too
Place photos, link previews, webhooks and the integrations fetch through the guard's own dispatcher and so ignored the outbound proxy, which left a server that can only reach the internet through one without them. They now read HTTP_PROXY, HTTPS_PROXY and NO_PROXY themselves and connect through the proxy, after the guard has vetted the target. The Helm chart passes the three variables through. (#1754)
2026-10-01 12:00:01 +02:00
Maurice 885896aba2 feat(files): FILE_UPLOAD_LIMIT_MB sets the upload limit for files and notes
The 50 MB limit for files, booking attachments and note attachments becomes FILE_UPLOAD_LIMIT_MB, 50 by default. The server hands it to the client through the app config, so the upload dialogs refuse a file over it up front and say what the limit is; on desktop a file over it no longer vanishes without a word. (#1364)
2026-10-01 11:56:14 +02:00
Maurice 1fee38e8f7 feat(oidc): OIDC_USERNAME_CLAIM picks the claim a new account's username comes from
Providers like Pocket ID send the full name in 'name', which became 'JaneDoe'. OIDC_USERNAME_CLAIM=preferred_username builds the username from that claim instead. Unset, or with the claim empty, the old order applies (name, preferred_username, the email's local part), and it is only read when an account is created. (#1677)
2026-10-01 09:25:23 +02:00
Maurice 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)
2026-10-01 09:22:37 +02:00
Maurice 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.
2026-09-30 23:28:14 +02:00
Maurice 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.
2026-09-30 23:00:12 +02:00
Maurice bd5a6cc748 fix(cors): the instance's own address no longer trips ALLOWED_ORIGINS
With ALLOWED_ORIGINS set, every request carrying an Origin that was not on
the list was failed with an unhandled error, which the exception filter
answered with a 500. A browser sends Origin on every POST and WebSocket
upgrade, including same-origin ones, so opening TREK under any address the
list did not name (the LAN IP, a second hostname, http instead of https)
broke the login with "Internal Server Error" while another browser that
used the listed address worked.

A request whose Origin is the host it was sent to is now treated as
same-origin and always passes, for REST and the /ws upgrade alike. A
foreign origin is still refused, now with a 403 "Not allowed by CORS" and a
warning in the log naming the origin to add.

Closes #2543
2026-09-29 21:16:26 +02:00
Maurice 7b0333a0c1 fix(roadtrip): a booked night no longer shows up mid-day when the day already ends at the stay
Booking a night writes the hotel onto its check-in day as a stop, seated first
or at its check-in, so the drive reaches it even without hotel bookends. With
"Start and end each day at your stay" on, the evening row already ends that day
at the same hotel, so the road trip drove check-out, tonight's hotel, the day's
places and back to the hotel again. Days hides that stop and draws the stay at
the day's edges only.

seatNightBookends now passes over the stop tonight's booking wrote whenever it
seats that stay as the day's evening, in the browser and in calculate_roadtrip
alike. The stops keep their stored index, a hotel the traveller placed stays,
and the booking's stop stays while it is the day's last one or no evening is
seated. With the switch off nothing changes.

Closes #2546
2026-09-29 21:16:26 +02:00
Maurice 64087512df fix(planner): the plan's costs follow the expenses in Costs
The day pills, Total Cost at the foot of the plan and the same figures in
the PDF added up the price column of every planned place. No form sets
that price since expenses replaced it, and nothing in Costs changes it,
so deleting an expense left the plan's total where it was, and a place
planned on two days counted twice.

With the Costs addon on, the plan now adds up the expenses: each one once,
on the day its booking starts, else the first day its place is planned,
else its date, and in the total either way. A foreign expense counts at
the rate frozen when it was entered and a refund nets against its day,
so Total Cost matches Total trip spend in Costs. With Costs off, a
place's own price still counts, once per place.

Closes #2551
2026-09-29 21:16:25 +02:00
Maurice bbf8d94f9f feat(share): the public trip page in the planner's design
The page a share link opens is rebuilt from top bar to footer: the
planner's light top bar with the language picker, the trip as a hero
made of its cover, a tab bar that stays in reach, the days as the
planner's cards beside a map that stays in view, bookings as the
booking cards, packing and costs as cards with their progress and
shares, the chat as bubbles, and empty states for sections the owner
shared with nothing in them yet.

The map picks its day in a select with a step back and forth instead
of a row of chips a three-week trip did not fit into.
2026-09-28 14:40:34 +02:00
Maurice 3901aecbc0 feat(trips): Share and Save to list in the new dialog look
Share opens on the shared dialog frame: the trip's name and the number of
people in the head band, members and guests on the left, the public link
and the invite link as two cards on the right. Rows carry their actions as
icon buttons named by their tooltips, and removing a member, leaving,
handing over the trip or removing a guest asks in the app's own dialog
instead of the browser's. The invite link can no longer be created twice by
a double click. The phone, which opens the same dialog from its More sheet,
keeps the sheet it had; both run on one set of hooks.

Save to list has the place's name in the head band, Mark visited as a pill
beside it, the lists as rows with a tick, and View at its foot.

The public link's hint loses its dash in every language, and the help
guides and wiki pages of the Share dialog show the new look.
2026-09-28 13:19:04 +02:00
Maurice af9710c618 docs(wiki): the pictures that were missing, and the follow-ups of the rewrite
The wiki pictures of the trip are taken again with the fixtures the help
guides use, so the Bookings tab shows a booking of every kind, Files has
documents and What's Next has something ahead. New pictures: the map's
hover card, the Subscribe to calendar dialog, the PDF preview, the To-Do
view, and the Transports tab's own timeline and booking detail. Day
selection in the screenshot run clicks the day's number, now that the
middle of a day header is the booked night.

Home, Real-Time Collaboration, Road Trip, Map Features and the bookings
page are brought in line with the rewritten pages, and sixteen assets no
page uses any more (mostly old GIFs, 22 MB) are removed.
2026-09-28 12:19:49 +02:00
Maurice cb7014b378 feat(costs): table view, and the wiki and help center for the new trip page
Costs gets a second view next to the ledger: the expenses as a table grouped
by category, with name, date, total, persons and days editable in place and
the per person and per day figures worked out beside them. A total with a
payer or in a foreign currency stays locked and opens the expense instead, so
balances and frozen rates are never changed behind the dialog. In the table
view the side column shows a summary by category, day, payer and status. The
switch sits in the bar and the choice is remembered; the ledger stays the
default.

On the Plan tab a place's reservation shows as one Reservation pill whose
colour carries the status and which opens the booking, and the drop line of
a drag that lost its source row now clears on the next mouse move. The
booking detail formats its times in the user's clock, and a hotel without a
check-out runs to the end of the last day in the timeline.

The wiki pages of the trip are rewritten for the new layout, with new
screenshots from the screenshot run (new Plan tab, bookings, transports,
dialogs, costs table). The help center guides of the Plan tab are rewritten
and translated into every locale, and every new string is translated.
2026-09-27 23:15:19 +02:00
Maurice 237f42a493 fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.

The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
2026-09-26 15:59:05 +02:00
RemEx 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.
2026-09-26 14:38:01 +02:00
Maurice 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.
2026-09-26 13:19:27 +02:00
Maurice a5745fd856 fix(ssrf): connect past a link-local DNS record instead of refusing the host (#2506)
Since 4.3.0 the guard resolves a name to every address it has, and a
single blocked address refused the whole name. LAN DNS servers commonly
publish a host's fe80:: address next to its IPv4, so a self-hosted IdP on
such a name failed every login with "Requests to link-local /
cloud-metadata addresses are not allowed". Immich, Synology and the other
strict-guard integrations on the same kind of name failed the same way.

Both guards now leave a blocked address out of the list the socket is
pinned to and connect over the rest, and a name that resolves to nothing
else is still refused. A private address among the remaining ones still
needs ALLOW_INTERNAL_NETWORK under the strict guard, and
ALLOW_LINK_LOCAL_IPS works as before.

Leaving addresses out is only safe if the blocked set is complete, so the
strict guard now blocks everything the admin lane does. The AWS IMDSv6
(fd00:ec2::/32) and Alibaba (100.100.100.200, 100.100.100.100) metadata
addresses sit in ULA and CGNAT space, which ALLOW_INTERNAL_NETWORK opened
to any URL a user can type. Both guards also read an IPv4-mapped or
IPv4-compatible address as the IPv4 it carries: Node prints an AAAA
record of ::a9fe:a9fe as ::169.254.169.254, which passed both guards, and
::ffff:100.100.100.200 passed the admin lane. Neither guard connects to a
link-local or metadata address now, and the strict guard never connects
to loopback either.
2026-09-26 10:36:02 +02:00
Maurice 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.
2026-09-25 15:34:32 +02:00
Maurice 1f79776070 feat(todo): To-Do in the look of the packing list, detail pane always in reach
Rows get the packing list's filled checkbox, priority, due date and list
as quiet badges, and the assignee as an avatar with the name as its
tooltip. The tasks sit in a card whose head names the filter and holds
the sort, Priority or Due date, which moved out of the sidebar; a second
click on the active one goes back to the manual order. The progress card
matches the packing list's.

The detail pane is a card with a box in its head that ticks the task
off, and Delete and Save changes stay in sight: the To-Do view now fills
the space under the Lists bar instead of a fixed height that pushed the
pane's foot off screen. The detail pane and the new-task form share one
set of fields, and a new list is named in a dialog, from the sidebar
and from the form; the dialog moved to shared/NameDialog for both lists.

The To-Do help pictures are re-rendered, the texts that described the
old sidebar and form follow in all 27 languages, and the wiki page is
updated. The phone screens are unchanged.
2026-09-25 13:29:06 +02:00
Maurice dc60f39554 feat(ui): Costs bar like the other tabs, desktop packing list in the phone's look
The Costs tab opens with the same bar as Transports, Bookings, Lists and
Files: the tab's name, the trip's dates and days in one pill, the
travelers, and Settle up and Add expense on the right.

The desktop packing list takes the phone's look without losing anything
it shows. Category cards get a tinted head band, rows get hairlines and
filled checkboxes, owner, quantity and weight read as small badges, and
the bin and a menu with Move to List, Sharing, Rename and Delete replace
the row of icons. What a row does not use stays dimmed until hover or
focus, so nothing leaves a gap. A click on the name renames, ticked
items too. Add item opens a composer shaped like the next row, Add list
moved into the Lists bar, Add list and Save as template ask for the name
in a dialog, and Remove checked sits beside the progress bar. The Bags
panel becomes a card, and the item menu, the bag picker, the list menu
and the bag member picker share one popover look and close on a click
beside them.

Help center pictures for Costs and Lists are re-rendered, the Lists
guide texts follow the new controls in all 27 languages, and the wiki
pages are updated. The phone screens are unchanged.
2026-09-25 12:57:15 +02:00
Maurice 5c312a8497 feat(packing): print, PDF and Markdown/CSV export of packing lists
The packing list bar gets an export menu next to the import button, both
now icon only. The menu prints the open view as a TREK styled page (trip,
dates, packed count, one card per category, bags), which the browser can
also save as a PDF, and downloads it as a Markdown checklist or as a CSV
in the exact format the import reads, bags included, so a list works as a
personal template without admin rights.

The import now also reads Markdown checklists: headings become
categories, "- [x]" marks an item packed, a "3x" prefix sets the
quantity and a "(200 g)" suffix the weight. The phone action sheet
offers the same exports through the shared usePackingExport hook.

The help center gets an export guide with new pictures, the import guide
and the wiki page describe the Markdown format, in all 27 languages.

Addresses discussions #1420 and #875.
2026-09-25 11:31:23 +02:00
Maurice 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.
2026-09-25 09:19:11 +02:00
Maurice 1727732841 feat(plugins): plugins can add POI categories to the map and answer them
A new manifest capability poiCategories (up to 4, icon from an allow-list,
strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the
hook:poi-category-provider permission. GET /api/plugin-pois in the plugin
contributions module answers them in the /api/maps/pois shape, with
search_plugin_pois as the MCP twin. plugin: ids no longer count as Google
place ids. SDK 1.8.0 carries the types, validation and docs.

On the map, one hook merges the built-in categories with the ones active
plugins declare, and the pill shows the plugin chips after a divider on
desktop and phone, scrolling sideways instead of shrinking a chip below
its touch target. A plugin chip searches through a repo that refuses
offline and parses the answer strictly. Its pins, on Leaflet and GL, use
the category's own icon and color, both checked again before they reach
any markup, and the hover card lists the plugin's details as escaped
text. A plugin POI can be added as a place like any other. The admin
panels on desktop and phone show a chip for the new permission and the
categories a plugin would add, from one shared module.

Addresses discussion #1781.
2026-09-25 01:24:55 +00:00
Maurice c7c9ba24a3 fix(journey): select all takes every photo in the range and imports keep their order
The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".

A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.

After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.

Addresses discussion #1587.
2026-09-24 23:48:49 +00:00
Maurice 650451bdf7 feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers
register per device, the server encrypts per RFC 8291 and signs per RFC
8292 with node:crypto and sends only to known push services through the
SSRF guard. The event matrix decides per category what arrives by push;
admins switch the channel on under Notifications.

VAPID keys come from the environment or are generated once, only while
neither stored row exists, and kept encrypted. A pair that does not
decrypt or match is never overwritten and never swapped for another one:
push stays off with an error in the log and every device stays
registered until the key is fixed. The subject is VAPID_SUBJECT, then an
https APP_URL, then the project URL.

Only chat messages collapse into one notification per trip, and a
replacement still alerts. A 403 removes a device only after three
failures in a row; 404 and 410 remove it at once. Password change, reset
and an admin setting a password drop the user's devices with the other
credentials. The request log redacts subscriptions and endpoints, boot
errors print *** for every secret variable, and logout forgets the
device with a plain fetch so an expired session cannot redirect midway.

Addresses discussion #894.
2026-09-24 23:17:03 +00:00
Maurice 7ce31756a3 feat(settings): every date picker starts its week on the day you pick
Settings > General > Language & region gets "Week starts on" (Monday,
Sunday or Saturday), and Admin > User Defaults a default for everyone who
has not picked one. Monday stays the built-in value, so nothing changes
for anyone who leaves it alone.

Both calendar implementations follow it: CustomDatePicker, used by trip
dates, bookings, costs, to-dos and the admin school holidays on desktop
and phone, and the journey date picker, whose weekday headers were
hardcoded English and now speak the app language. The grid math and the
header row live in one helper (utils/calendarWeek.ts). The day names on
the options come from Intl, so only the label and a hint needed
translating. Vacay keeps its own per-plan week start.

The value is validated like the other regional settings: VALID_VALUES for
admin defaults, the display-settings MCP tool, and the managed key list.

Addresses discussion #2029.
2026-09-24 21:56:12 +02:00
Maurice bb5c5dcfc1 docs: TREK ships 27 languages now
Azerbaijani, Estonian, Slovak and Thai joined; the README and the wiki
still counted 23.
2026-09-24 17:35:25 +02:00
Maurice b7f777024a feat(i18n): bring the Thai translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers th
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:29 +02:00
Maurice 3c036ea25d feat(i18n): bring the Slovak translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers sk
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:34:09 +02:00
Maurice ffa085aef7 feat(i18n): bring the Estonian translation up to date with dev
Translates the keys dev gained since the branch was cut, the help center
above all, and drops the six keys dev no longer has. Registers et
everywhere a language is listed: notification emails, the rate-limit
message, the placeholder check and the wiki. The two tests that counted
the languages by hand now derive the count from SUPPORTED_LANGUAGES.
2026-09-24 17:33:51 +02:00
Jaano Rosin 9263421c5e feat(i18n): add Estonian (et) translation 2026-09-24 17:33:51 +02:00