Commit Graph

461 Commits

Author SHA1 Message Date
jubnl bc4d70986f feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule 2026-08-19 20:27:50 +02:00
jubnl 8ae73a2b5c test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract 2026-08-19 20:20:58 +02:00
jubnl 773eda63d9 Merge remote-tracking branch 'origin/dev' into feat/storage-driver
# Conflicts:
#	server/src/db/migrations.ts
#	server/src/nest/journey/journey.controller.ts
#	server/tests/integration/leg-mode-incoming.test.ts
#	server/tests/unit/nest/journey.controller.test.ts
#	server/vitest.config.ts
2026-08-19 19:52:49 +02:00
Maurice 503a03792e test(photos): cover the local EXIF path so the memories ratchet holds
The capture backfill landed in src/nest/memories/ with its local branch
untested, which took the domain under its coverage floor (functions 96.71 vs 97,
branches 79.67 vs 81). Raising the tests rather than lowering the gate: the path
guard against climbing out of the uploads tree, a missing path, an unreadable
file, the CreateDate fallback, empty EXIF, a throwing row lookup, and schedule().
Plus the Immich album mapping with and without coordinates.

One note left in the test file for the next person: a mock that *rejects* makes
vitest fail the run even when the code catches it, because the mock's recorded
result is a second reference to that promise with nothing attached. A bare
mockReset() is enough to trip it, so the beforeEach sets a benign default.
2026-08-18 22:24:29 +02:00
Maurice 06b7f3e690 feat(weather): answer for the hour when a past entry carries one
A journal entry made at 14:30 wants the weather of that hour. The archive path
only ever asked for daily figures, so it answered with a rainy morning and a
bright afternoon averaged into one number (#1614).

With a time in hand it now asks for the hourly series as well and reads that
hour, falling back to the daily figures when the hour has no reading — an
archive gap must not turn into no answer at all. An unparseable time is treated
as no time rather than as midnight, which would have been a confident wrong
answer. The cache key carries the hour, so the two do not shadow each other.

Nothing changes for callers that pass no time, which is every existing one.
2026-08-18 22:24:29 +02:00
Maurice 6e4fda897f refactor(journey): stop pulling trip photos into journeys
Photos live in journeys (#1614). Linking a trip used to copy its trip_photos
rows into the journey gallery, which is how a photo one member had chosen not
to share could reach a journey at all — PR #1967 filtered that at the door, and
this removes the door.

Nothing is lost by it: the trip-photo surface lost its UI in 3.1.0, so nothing
writes to trip_photos on an install newer than that, and the client calls none
of its routes. The table and those routes stay for one more release rather than
being dropped in an append-only migration; the read paths keep working for
installs that still hold rows.
2026-08-18 22:24:29 +02:00
Maurice 56b4509b03 feat(journey): put photos on the map where they were taken
The visible half of the capture-metadata work (#1614). Photos that know their
coordinates are drawn as thumbnails on the Journey map, in the shape the report
asked for: a picture rather than an abstract pin, and a count when several
collapse into one.

Clustering is a screen-space grid rather than a library. The Journey maps have
never had clustering, and the one the planner uses hangs off react-leaflet while
this map drives Leaflet directly; bucketing by rounded pixel position is a few
lines, is deterministic, and does the job — photos of one place collapse into one
thumbnail with a count, and pulling the map apart separates them again. The layer
sits below the entry pins, because the itinerary is the point of the map and the
photos are context.

Its own effect, so photos arriving do not tear down and rebuild the map the way
the entry effect does, and redrawn on zoom and pan because the buckets are in
screen space.

The thumbnail URL is built in the page rather than the hook: it needs the share
token, which is the credential the byte proxy checks.

Also re-points the trailing-migration guard at the new last slot, which is what
its own comment asks of anyone appending one.
2026-08-18 22:24:29 +02:00
Maurice 97220b72f6 feat(journey): carry capture metadata into the payload, and keep it behind share_map
The gallery rows now hand the client taken_at, lat and lng, which is what the map
and a capture-ordered gallery will read.

The coordinates needed a projection before they could ship. A photo's capture
location is a place the owner never typed and may not expect to publish, so it
follows share_map — the same switch the entry coordinates already follow. Without
that, turning the map off and leaving the gallery on would have handed out exactly
the locations the owner had just withheld. The capture time is not a location and
stays either way; a blog-style view sorts by it.
2026-08-18 22:24:29 +02:00
Maurice 359c3afc21 feat(photos): ask the provider where and when, and stop the album path dropping it
Second half of the capture-metadata foundation (#1614).

The album paths were the asymmetry: Immich's search has always carried
exifInfo.latitude/longitude, but the album mapping quietly left them out, and
the Synology album listing never asked for the gps block at all. So a photo
picked out of an album could never be placed on a map, and the distance sort
that PR #1632 added silently had nothing to sort by. Both now carry the pair.

For the add itself the client only sends an asset id. Widening that contract
would have meant trusting the client for something the provider can be asked
for directly, and would have left the MCP path and every already-imported photo
without it — so PhotoCaptureBackfillService asks afterwards instead, detached,
skipping rows that already know both so an album import is not one provider
call per photo. A slow or unreachable provider cannot hold up the add, and a
photo without capture metadata stays the normal case.
2026-08-18 22:24:29 +02:00
Maurice 6185cf96b6 feat(photos): record when and where a photo was taken
The foundation the Journey map needs before photos can sit on it by their own
coordinates, and the gallery before it can order by when a picture was taken
rather than when it happened to be added (#1614). Three nullable columns on
trek_photos plus a partial index over the coordinate pair; a photo with neither
is the normal case, not a failure.

recordCaptureMetadata merges rather than overwrites, for the same reason
recordLocalThumbnail does: the sources disagree about how much they know. A
provider search answers with coordinates, the same provider's album listing does
not, and a local file gives them up only once its EXIF has been read. Whichever
arrives first wins, and a later, emptier answer must not blank it. Coordinates
are stored as a pair or not at all — half a pair is not a place, it is the null
island.
2026-08-18 22:24:29 +02:00
Maurice 630e8efca1 fix(journey): stop unshared trip photos leaking into journeys, and gate the link
Two things found while reading the domain for discussion #1614.

A photo a trip member left unshared is denied on the trip path — memories-access
requires tp.shared = 1 — but linking that trip to a journey copied every row of
trip_photos into the gallery regardless. From there it was readable by every
journey contributor, and with an open gallery by anonymous visitors of the share
link. journey_photos.shared cannot gate that after the fact: a direct upload
writes 0 into the same column, so filtering on it would empty most galleries.
The fix is at the door instead — only shared rows are copied in.

addTripToJourney checked that the caller could reach the trip but never that
they could reach the journey, so any logged-in user could link a trip of theirs
into a stranger's journey and seed skeleton entries and photos there. The MCP
tool has always checked this; only the REST route was open. Now the service
checks it, which covers both and the internal call from createJourney.

Neither path had a test. They do now.
2026-08-18 20:17:57 +02:00
Maurice 28765e220b fix(places): keep the Google feature id, and give list imports the free address lookup
Two halves of #1954, both about a place arriving through a list coming out
thinner than the same place pasted as a single link.

The feature id was never dropped on purpose. googleFtidFromMapsUrl only looked
at the `?ftid=` query parameter, while a share link that resolveGoogleMapsUrl
has followed to its final hop carries the id in the `data=!…!1s0x…:0x…` path
blob — the same run the coordinates are already parsed out of two lines further
down. So every pasted maps.app.goo.gl link lost it, and the Maps button opened
a name search rather than the entry the link pointed at. The parser now reads
the blob too, but only on a /place/ URL: a /maps/dir/ route carries one !1s per
waypoint and the first is the origin, which would attach the wrong place.

List imports never had a reverse lookup at all. The only pass behind them is
the Google enrichment, which returns immediately without a Maps key, so an
instance without one imported a list and left every address empty forever.
They now get the same free Nominatim lookup the single-link path has always
had: detached after the import answers, on the throttled background lane so it
never queues in front of someone's search, through COALESCE so it can only fill
an empty column, and each row pushed over the websocket so the sidebar fills in
without a reload. Capped at 250 places — past that it stops being a backfill
and starts being bulk geocoding.

Not included: an osm_id for imported places. Single links do not produce one
either, and one derived from a zoom-18 reverse lookup would name the building
or the street rather than the POI.
2026-08-18 17:40:56 +02:00
Maurice 02f7320c90 fix(mcp): name the route profile keys the router actually accepts
Both tool descriptions offered "transit" as an example. RouteCalculator only
knows driving, walking and cycling plus the plugin:<id>/<profile> form, and
maps anything else to driving when it draws the route, so an agent that set
"transit" got a success and a car route with a car icon. Name the real keys
and say plainly what happens to anything else.

The incoming-leg test used "transit" too. It now uses walking, and the
outgoing test seeds the incoming column first, so its "must not touch
incoming" assertion proves something the factory did not already give it.
2026-08-18 13:56:44 +02:00
Ida Rogalska e375f9e002 feat(mcp): add set_day_default_transport_mode tool
Wrap DaysService.setDefaultTransportMode behind an MCP tool, mirroring the
days/:id/transport REST route. Adapter only; per-segment leg modes still
override the day default.
2026-08-18 13:56:44 +02:00
Ida Rogalska 03f3c5ae36 feat(mcp): add set_leg_transport_mode tool
Wrap the existing per-leg transport setters (outgoing + incoming) behind an
MCP tool so leg modes no longer need a raw DB write. Adapter only, no new
business logic; mirrors the assignments/:id/transport REST route.
2026-08-18 13:56:44 +02:00
michael-bohr 55aef8d55c fix(tests): generate trip days in UTC so a DST shift can't drop or repeat one
The test trip factory parsed date-only ISO strings as UTC midnight but stepped
the cursor with setDate(), which advances local wall-clock time. Across a DST
transition that step is 23h or 25h, so the instant drifts off midnight and
toISOString() reports the wrong day — a day is dropped in autumn and repeated
in spring.

TRIP-SVC-015 already builds a trip over 2025-11-01..2025-11-03, which straddles
the US fall-back, and fails on any machine in a DST-observing zone:

  AssertionError: expected [...] to have a length of 5 but got 4

CI runs UTC, where the loop happens to be correct, so this is invisible there
and only bites contributors running the suite locally.

Full server suite: 396 files, 7550 tests, green.
2026-08-18 13:37:06 +02:00
Maurice 84d9fbff3c fix(scripts): rotate the instance-wide provider keys too
#1939 moved the Google Places and Unsplash keys into encrypted app_settings rows,
but the rotation script walks a hardcoded list of app_settings keys and the two
new ones were not on it. Rotating ENCRYPTION_KEY would have left them encrypted
under the old key, which reads back as null and silently drops the install to
OpenStreetMap with nothing in the log to say why.

users.unsplash_api_key was missing from the users column list for as long as the
column has existed. It matters now that the resolver reads that column as the
per-user fallback, so it goes in at the same time.

The script cannot import the canonical list, since it deliberately runs without
config.ts and without the container, so the copy is pinned by a parity test that
reads the script and fails when a name is missing from the app_settings loop.

Wiki: the rotation page listed the Maps key as per-user and did not mention the
app_settings rows an operator now has to carry across.
2026-08-17 18:33:43 +02:00
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00
Maurice 54693474f1 fix(atlas): stop counting a connection airport as a country visited
The role IN ('from','to') filter from #1486 is correct and still in place, but it
cannot see the case this issue actually reports. Joining AirTrail legs into one
booking with a layover stop is opt-in, so legs imported separately store the hub
as the legitimate `to` of one booking and the `from` of the next, which no role
filter can tell apart from two real arrivals.

Pair them where the countries are counted instead: an arrival and a departure at
the same airport, in different bookings of the same trip, within 24 h of each
other are a connection, and neither endpoint counts as reached. 24 h is the same
window the importer already uses to chain same-PNR legs. Doing it on the read side
means the databases that already hold these flights heal on the next request.

Only flights pair, and never cancelled ones, so a rental car picked up at the
airport still counts its country (#1366). A booking that returns to where the
chain started is a return flight, and that check compares distance rather than
airport identity, so flying home into a different airport of the same metro area
does not erase the country you spent the day in. A country with a place logged in
it, or marked by hand, is untouched.

Closes #1535
2026-08-17 14:48:23 +02:00
Maurice 1a0834b866 fix(maps): resolve the Places key per instance, not per whoever is asking
Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
2026-08-17 14:48:23 +02:00
jubnl 405263d015 test(server): resolve async backupFileExists mocks for the tests typecheck 2026-08-17 08:05:11 +02:00
jubnl cd40369590 Merge branch 'origin/dev' into feat/storage-driver
Both sides appended a migration to the same schema_version slot: dev's
reservation_day_positions cross-trip cleanup keeps its published index,
the storage slice-2 trip_files prefix strip moves after it. The trailing-
migration guard now rewinds two versions and asserts both. Dev's new
places image_url/website tests gain the StorageService constructor arg
and async update() handling from the storage slices.
2026-08-17 08:00:41 +02:00
jubnl 070d08da5e test(server): close slice-5 gaps surfaced by the coverage tier 2026-08-17 07:48:02 +02:00
jubnl ceb9af2480 refactor(server): boot dir-creation to driver init 2026-08-17 07:34:15 +02:00
jubnl 633103a246 refactor(backup): auto-backup retention through storage 2026-08-17 07:29:22 +02:00
jubnl 3838277048 refactor(backup): restore and upload-restore through storage 2026-08-17 07:22:17 +02:00
jubnl 4b8c6013a6 refactor(backup): create backup through per-category storage enumeration 2026-08-17 07:13:15 +02:00
jubnl e4109db9f6 refactor(backup): download through storage sendToResponse 2026-08-17 07:03:13 +02:00
jubnl 8039362e04 refactor(backup): list, existence and delete through storage 2026-08-17 07:00:00 +02:00
jubnl af85d39799 test(server): close slice-4 gaps surfaced by the full tier 2026-08-17 06:32:32 +02:00
jubnl 5870dd4fda feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim 2026-08-17 06:23:13 +02:00
jubnl 616897b60b fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep 2026-08-17 06:19:31 +02:00
jubnl 0962df2f22 refactor(journey): photo deletion through storage 2026-08-17 06:17:24 +02:00
jubnl 939c2ec3e8 refactor(memories): immich upload reads through storage; drop uploads-root 2026-08-17 06:15:01 +02:00
jubnl 364f376b07 refactor(memories): thumbnail service category-addressed 2026-08-17 06:08:04 +02:00
jubnl 1a2e4d4ef2 refactor(memories): trek photo cache internals through storage 2026-08-17 06:05:52 +02:00
jubnl f8faef77c4 refactor(files): trash deletion through storage 2026-08-17 06:01:28 +02:00
jubnl f382a7635e refactor(collab): note-file deletion through storage 2026-08-17 05:51:44 +02:00
jubnl dfb8f8a8a4 refactor(auth): avatar reclaim through storage 2026-08-17 05:47:31 +02:00
jubnl 8a3327c5f6 refactor(trips): trip covers through storage 2026-08-17 05:37:52 +02:00
jubnl 965566369e refactor(places): reclaim place images and collection covers through storage 2026-08-17 05:31:53 +02:00
jubnl 4eca78b53e refactor(place-photos): route place-photo cache through storage 2026-08-17 05:20:09 +02:00
Maurice c9124c9b63 fix(places): only attach tags owned by someone on the trip
A tag belongs to a user, not to a trip, and the place create/update body is an
open record the Zod pipe does not police — so any tag id could be attached, and
the tag projection ships tags.user_id, which answers who owns a tag the caller
has no way to see. collection_place_tags had it verbatim.

Scoped to the roster rather than to the caller on purpose: a member tags a shared
place, another member re-saves it and, because the update is delete-then-insert,
sends that id back. Caller-scoping would drop a co-traveller's tag on every
foreign edit; roster-scoping keeps it. Collections resolve the same question
through their own member list.

No client sends a tags array today, so this only closes the hand-crafted path.
2026-08-16 23:17:50 +02:00
Maurice 244eb56e4c fix(budget): confine payers, split members and settlement parties to the trip
knownUserIds asked whether a user exists at all, not whether they are on this
trip, so any id on the instance could be made a payer or a split member. Both
read-backs join users, and the settlement projection carries the avatar through
avatarUrl, which passes an absolute URL straight out — for an OIDC account that
is the picture claim.

Payers and split members drop off-roster ids, as before, only against the right
set. Settlements cannot: they name exactly two parties and both columns are NOT
NULL, so dropping one would either break the constraint or move money to the
wrong person. Both wrappers refuse the write and return null, which lands on the
404 the update path already had. That answer is deliberately the same one a
missing settlement gets, so the endpoint says nothing about whether an id exists
— and it retires the foreign key error that used to surface as a 500 and drew
exactly that distinction.

createSettlement grows the 404 its sibling had; the MCP tool reuses the error
string already at hand. Money rules are untouched: a settlement is a transfer, so
refusing one cannot unbalance the sum, and the cent arithmetic is unchanged.
2026-08-16 23:17:50 +02:00
Maurice 0337ebbbd2 fix(assignments): confine day participants to the trip roster
setParticipants took the ids on trust. The caller is proven to be on the trip and
the assignment is proven to sit on it, but nothing said the same about the ids in
the body, and the read-back joins users — so a stranger's name and avatar came
back to the writer and went out over the assignment:participants broadcast to
everyone else in the room.

Takes the tripId both callers already hold and filters against the roster. Same
drop-rather-than-reject rule as bag members and reservation travellers: the
participants box posts the whole list on every edit, so a trip whose membership
changed underneath it would otherwise be stuck.

ASG-SVC-028 covered a foreign key error rolling the replace back. The filter
takes an unknown id out before the insert can reach it, so that path is gone and
the case now pins what happens instead: a list of nothing but strangers clears,
like the empty list ASG-SVC-018 already pins.
2026-08-16 23:17:50 +02:00
Maurice 1eb29f8b77 fix(trips): confine assignee writes to the people on the trip
Holding packing_edit or todo_edit says you may edit this trip's lists, not that
any user id you name belongs to it. Both updateCategoryAssignees loops wrote the
body's ids straight through, and both end by re-selecting through a JOIN on
users — so an id from anywhere on the instance came back with a display name and
an avatar attached, in the response and in the WebSocket fan-out to the room. The
packing item recipient writes had the same gap.

packing already had tripRosterIds for exactly this and used it only for bag
members; its doc comment said "assigned to a bag", which is probably why the
other three writes grew up without it. The query moves to DatabaseService next to
canAccessTrip, which asks the same question as a predicate, and the helper now
delegates. Every service that needs it already injects DatabaseService.

Off-roster ids drop rather than 400: a copied trip carries assignee ids over
before its members exist, and rejecting the whole request would stall the picker
there. Guests are unaffected — a guest is a trip_members row, so the roster
includes them, which is the point of #1362.
2026-08-16 23:17:50 +02:00
Maurice c8607ba234 fix(places): pin the image and website URL fields on the way in
image_url and website were bare strings in the contract, and the create/update
bodies are open records, so the Zod pipe never looked at either. Both leave the
database for something that treats them as a URL: the thumbnail is interpolated
into hand-built marker HTML, and the homepage is handed to window.open, where a
javascript: value would run in this origin rather than opening a page.

Both are allow-listed on the way in now, next to hexColorSchema and checked in
the handler the way validateRouteColor already is, on all three write surfaces
so REST, the plugin RPC and MCP agree on what they accept. The entity schemas
stay open: rows written before this still have to round-trip.

safeHttpUrl covers the read side for exactly those rows, the same split
safeHexColor uses, and the window.open calls it guards pick up
noopener,noreferrer while they are being touched.
2026-08-16 21:57:21 +02:00
Maurice 0f0bf50cd3 fix(assignments): guard the per-assignment routes like the day routes
AssignmentOpsController declared @RequirePermission('day_edit') on its
mutations but carried only JwtAuthGuard. TripAccessGuard is what reads that
decorator and what resolves :tripId, so on this controller the permission
markers were metadata nobody looked at and the trip in the URL was never
matched against the caller. Its sibling, DayAssignmentsController, has had the
pair since the migration.

The two participants handlers also went straight to the service with the id
from the path, unlike move/time/transport, which ask getAssignmentForTrip
first. They ask now too.
2026-08-16 21:57:21 +02:00
Maurice ff27624e02 fix(reservations): scope the per-day position write to the trip
updatePositions has two branches. The legacy one updates
`WHERE id = ? AND trip_id = ?`; the per-day one, three lines below it, bound
the caller's ids straight into reservation_day_positions and never touched the
tripId it was handed. That table has no trip_id of its own and its two foreign
keys only require the ids to exist, so nothing further down caught a pair that
did not belong together.

The trip now comes from the join, so the row materialises only when the
reservation and the day agree on it. Doing that in the statement rather than as
a pre-check also turns a stale id into a quiet no-op instead of a foreign-key
error surfacing as a 500, and lets an omitted day_plan_position bind the way
the wire contract has always said it may.

reorder_reservations reports a day that is not on the trip, the way its sibling
tools in that file already do, and a migration clears rows whose reservation
and day disagree.
2026-08-16 21:57:21 +02:00
jubnl 31478e232a test(server): close serving-swap gaps surfaced by the full tier 2026-08-16 18:36:38 +02:00