mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
0e2d255d81d15f2399a6c8b02a5bf8e8134dfdcc
152 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
185f6c5335 |
Merge pull request #2373 from liketrek/fix/journey-skeleton-per-day
fix(journey): a place kept across two days is two days of the journal |
||
|
|
5f57c63426 |
fix(journey): make the source_assignment_id migration replay-safe
An unreleased migration has to survive being replayed on a schema where it already applied — installs skip releases, and a test rewinds a fully migrated database to the last released version to prove it. The bare ADD COLUMN threw "duplicate column name" on that replay, which took every migration test down with it. Guard the ALTER on pragma_table_info, and add `source_assignment_id IS NULL` to the backfill so a replay cannot re-resolve a row that has since followed its stop to another day. The trips e2e harness hand-builds a minimal journey_entries table rather than running migrations, so it needs the column spelled out too. |
||
|
|
acd6f1acdf |
fix(atlas): date a country's first and last trip from the trips that count
The Atlas tooltip took both dates from the start dates of every trip with a place in the country. One trip showed the month it began twice, the month it ended never appeared, and a trip only booked could become the last visit of a country already visited. Both now come from the trips that give the country its status, places and bookings alike: the start of the earliest and the end of the latest, with a trip still under way ending today. The optimistic mark on the client clears the dates the same way the server does. The second label rendered the raw key atlas.lastVisitLabel since #1908 removed it from the locales; the strings are back as they were. The month is built from the date's digits now, because a 'YYYY-MM-DD' string parses as UTC and showed the month before anywhere west of Greenwich. Closes #1535 |
||
|
|
00d00be1c0 |
feat(roadtrip): what a charger costs, before it is a stop
A tester found the charging panel once a station was on the trip and asked for it while still deciding. The availability and the published tariffs were reachable only for a saved place: the route takes a places id, and a hit found along the route has no row yet. ChargingService.load already needed nothing but a coordinate and a name, so it now has a public method that says so, behind a trip-scoped route with the same guard stack, a Zod contract in shared, and the parallel MCP tool in this same change. Same service, same caches, so a station looked at in the dialog and then added keys the entry already fetched. Trip-scoped even though the trip is not part of the question: without :tripId the trip access guard goes too, and what is left is an open proxy in front of somebody else's public API. It renders in the add dialog, and only while the selected kind is charging, so a fuel or food stop costs nothing and one opened dialog is one lookup. Deliberately not per result row: the corridor search publishes box by box, every mounted panel owns a refresh timer, a cache miss can cost seven upstream requests, and a station in a dense charging park is the case the upstream matcher refuses as ambiguous. Power and socket counts are not in this answer and were not asked of it. They are OSM tag data and already ride along on the corridor row for free. |
||
|
|
c42cf5477b |
fix(public-api): a key narrowed away from days stops getting days back through places
Places, notes and reservations hang off days, so asking for one of them implies the day container. That implication ran ahead of the grant: a key minted with scopes ['trips','places'] and deliberately no 'days' still received every day's title and the day's own free-text notes, either by naming places or by naming nothing at all, since an absent include narrows to the granted sections and lands back in the same branch. Asking for days by name was a 403 the whole time, so the two routes disagreed about the same fields. The grant now reaches the service alongside the narrowed include. The container is still implied, because the places inside it are useless without their date, but the day's title and notes only go out to a key that was granted days. A key with full access is unaffected: it is granted days whether or not it asks. |
||
|
|
8eb936f46f |
fix(test): the places e2e knows the table the delete path reads
Deleting a place cancels the nights booked at it, and that query hits `day_accommodations`. This file builds its own minimal schema and did not have the table, so every delete answered 500 and three cases failed. The assignments e2e was given it for the same reason; this one was missed. This is the failure that has been keeping Server Coverage red on dev. |
||
|
|
c2707b7eff |
feat(journey): the enhancements from discussion #2299
roel-de-vries pitched seventeen changes to Journey. Four were already built or rest on a misunderstanding, one cannot work as described, and three would take away more than they give; the rest are here, plus two things the list did not ask for. **The entry you write on the move now has a name.** Quick capture hid the title field outright and offered a note instead, so every entry caught on a phone arrived nameless and a day's timeline read as a column of identical placeholders. One line of input; it is the single change in this branch that users will feel most. **The map stops yanking.** Stepping through the timeline called flyTo with a zoom floor of 12, so reading a journey from a country view snapped to street level on the first scroll and stayed there — every marker filling the screen, and no sense of where anything was. Both renderers pan now and leave the frame the reader chose. The initial fitBounds still decides how close a journey opens. **The phone timeline is photos.** A 64px thumbnail beside three lines of text spent the card on a story nobody reads at that size and truncated the title after a few characters. The picture is the card now, with the name and place on it, the country's flag and the date in the corners, and the day carried as a colour rather than as a number nobody was reading. Above the carousel, a segment per day: it says how long the journey is, how far in you are, and takes you to day nine without swiping past days three to eight. Everything else, briefly: - A plus in each day header, starting a draft on that day instead of on today. - A journey opens on today when today is part of it, and at the beginning when it is not — a journey that ended last spring is something you read. - Suggestions can be waved away one at a time. The row survives so the trip sync does not offer the same place again; journey settings brings them all back. - Mood, weather and the pros/cons list can be switched off per journey. Nothing written is erased — the form stops asking, the values stay. - The weather fills itself in at the desk the way it already did on the phone. The date picks the source, so writing up last Tuesday gets last Tuesday's. - Enter opens the next pro or con, below the one you are in. - The verdict moved behind the story's fold, and the fold itself came in from nine lines to four. - The external picker lost the count it was printing twice and some padding it could not afford inside the editor. - The phone can reorder entries within a day at last; the desktop arrows finally have translated labels. - Marker labels are off where a carousel already names the entry, and on where they are the only name a marker has. - Search over a journey's titles, stories, places and tags, accent-blind. - An entry knows which country it happened in, resolved once from its coordinates against the bundled boundaries. Not built, and why: the photos-for-markers idea needs clustering first or thirty markers become soup, and the active entry's photo is already on screen in the card below. Removing mood, weather and the verdict outright would take them from everyone who uses them, so they became switches. `capture` on the file picker takes the photo library away on a phone, which is why the camera is a second input; it is hidden on desktop now instead. The suggestion strip in the new-entry flow would be a third surface over the same data — tapping a suggestion already opens the editor filled in. The one gap the list missed and this branch does not close: Journey is the only domain with no offline path. That belongs in its own change. |
||
|
|
fcee6bd2c5 |
fix(roadtrip): sort a booked night by its check-in, and clear the lint the cut left
Pinning an afternoon stop sorted that one and left the hotel it was booked around sitting behind it. The day's auto-sort reads each stop's own hour, and a booked night has none: nobody types a time into a hotel row, they type a check-in, and that lives on the booking. It counts now, so the night moves with everything else instead of staying where it was dropped. The assignments e2e builds its own tables and had neither the accommodation column nor day_accommodations, so every create in that file 500'd as soon as the insert learned to carry a booking id. Both are there now. And the shared lint, which is stricter than the other two and which I had not run: three leftovers from taking the check-out out of the chain, none of them used any more. |
||
|
|
f0e1da2d81 |
feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the map all build their stops from day_assignments and only look the stay up afterwards, to hang check-in and check-out on one. A stay written without an assignment was therefore invisible to every routing surface, so a hotel entered in the day planner never reached the road-trip view and had to be entered a second time as an ordinary place. The road-trip side has always written both halves in one go; this is the day planner catching up, in the domain that writes the stay. Booking a night now also assigns its place to the check-in day, inside the transaction that already writes the stay and its partner hotel reservation, and types the place as a hotel stop when it carries no type of its own, so it reads exactly like a night booked in the road trip. Only the check-in day gets a stop; the later nights ride on the stay row. A place already planned for that day gets no second stop and the booking claims none, so cancelling it cannot delete a stop somebody placed by hand. Moving the booking moves its own stop, deleting it takes its own stop back, and a trip re-date carries it along with the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the same write and announce the same events; the answers carry the stop too, because the broadcast skips the socket that sent the request. New nullable day_assignments.accommodation_id marks a stop as a booking's own. It stays NULL for every existing row on purpose: everything planned before this was planned by hand, and no backfill rewrites a finished trip. A stay booked earlier picks up its stop the next time somebody saves it. The booking form keeps writing stays on its own: that surface is gated on reservation_edit and the day stop is day_edit work. |
||
|
|
8a28d4dd4a |
test(server): stop racing supertest's own listener in the scope e2e
Three cases fired their reads through `Promise.all`. The Nest app under test is never told to listen, so supertest binds an ephemeral port on the first request; four of those at once race four `listen(0)` calls on the same server and the losers come back as ECONNRESET. It held here and failed on a loaded runner. The reads run one after the other now, which is all they ever needed. |
||
|
|
218d8baf0a |
feat(server): read visits and recorded routes from Dawarich
A new `integrations/dawarich` domain behind its own addon. Each reader connects their own instance; the API key is encrypted at rest and never returned. Nothing is written back to Dawarich, and no raw position is kept in TREK: the recorded route is fetched per request for the window being looked at and thrown away again. What it produces are suggestions, never facts. A recorded stay becomes a proposed place or journal entry, a country or city becomes a proposed Atlas mark, and a stay near a wish becomes a proposed tick-off; each one waits for the reader to confirm it. Identity is (user, Dawarich visit), so a second sync neither duplicates nor overwrites what was accepted, and deleting the result puts the suggestion back on the list. The sync runs as a job on the central scheduler, one user at a time, guarded against overlapping runs. |
||
|
|
dad1316172 |
feat(api): let public API keys be scoped to single areas
A key could only ever read everything a user owns. When a second service reads TREK on that user's behalf, that is more than it needs: Dawarich wants trips, days and places, not notes, bookings or the wish list. Keys can now be minted with an explicit list of areas, checked in the guard and shown when the key is listed. Keys that existed before this keep full read access, so nothing that works today stops working. A key marked as limited whose list cannot be read grants nothing rather than everything. |
||
|
|
11b69ae815 | test(places): give the hand-built places tables the Amap id column | ||
|
|
259a8c6b4d |
test(budget): settled_at through the HTTP pipe
The e2e settlement round trip now sends settled_at on create and update and reads it back from the column, including a PUT that clears it to NULL. The service test covers the clear branch for null and the empty string the date picker sends. |
||
|
|
ac57321840 | merge dev and reconcile roadtrip planning | ||
|
|
f56e7a161e | add open charging data and polish roadtrip warnings | ||
|
|
41b18f970c | feat(vacay): add manual school holiday calendars | ||
|
|
ff5fa55fad | add Google Maps route import and plugin corridor search | ||
|
|
af45b0ca74 | add roadtrip warning areas and tidy driving settings | ||
|
|
b773faff70 | feat(roadtrip): share trip preferences and improve service stops | ||
|
|
96ab748f34 |
feat(roadtrip): let one stop say how full it fills
The fill level was one figure for the whole trip, and a trip does not have one. A motorway rapid charger is worth about 80 % because the last fifth costs as long again; the charger at the hotel is worth all of it because the car stands there all night. Between those two the difference is a whole leg, and a single setting has to be wrong about one of them. So the badge on a refuelling stop is now the way in: it shows what that stop will actually fill to, its own figure or the traveller's, and opens a panel of the six answers people actually give. Under them it says what the choice buys in kilometres, because 80 % is an abstraction and "about 290 km from here" is the thing being decided. Picking the figure a stop already has hands it back to the setting, the same gesture the kind picker uses to un-pick a kind. The value lives in places.fill_percent, beside stop_type, because it is a property of the stop rather than of the person. Null means "follow the setting", which is every stop that predates the column and every stop nobody has an opinion about, so nothing changes for a trip that never touches it. Two pieces of shared machinery came out of it rather than being copied. The popover chrome — measure before showing, flip when the window is short, close on an outside click, Escape or a scroll — is one component now, used by the kind picker and this one. So is the assembly of the range from seven settings, which the routes hook and the rail both need and which would have drifted the first time a field was added. |
||
|
|
77162aa8d5 |
feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
|
||
|
|
2d2f1e88bd |
fix(collab): sync shared links live, and teach the harnesses the new columns
The three collab:link events were broadcast and landed nowhere: the links panel held its own list and never listened, so a link somebody else added showed up on the next reload. It listens now, the same shape the notes panel uses, and the events are classified in the client's WS policy, which is the ratchet that caught this. Four e2e harnesses build their own trip_files and one asserts the collab feature set, so the new message_id column and the new links flag had to reach them too. Two MCP unit tests pinned the old four-key feature object. |
||
|
|
7e3b9fd039 |
fix(collab): keep every panel on screen, and stop leaking spool and links
The desktop layout rendered notes and links as a pair and dropped whichever of the two was on its own, so turning the new Links feature off on an otherwise default install made the Notes panel disappear. Both rows are now split by kind and rendered only when they hold something, and one function decides what a panel id renders, because the branch that lost a panel did so by not listing it. The message and its attachment rows are written in one transaction. The caller has already committed the image bytes by then, so a message that landed without them left those bytes with nothing pointing at them and nothing to sweep them. Deleting one now drops the blobs after the rows are gone rather than before. A body the Zod pipe rejects used to leave up to four spooled images on disk: pipes run after interceptors, so multer had already written them and the handler's own cleanup was never reached. An interceptor removes them on any failure before the handler, which also covers every other schema mismatch, and the length check that could never fire is gone. Adding, pinning and deleting a link now says something when it fails instead of raising an unhandled rejection, the list has real types, and the rejected-image toast is translated in all 23 locales rather than shipping English everywhere. The picking, previewing and clearing of chat images moved into one hook both shells use: it was copied into two, and both rebuilt every preview on each change and leaked an object URL per image per pick. Tests: the four link routes end to end with the validation pipe the harness was missing, the image filter against the disguised-extension case, and the layout combinations that drop a panel. |
||
|
|
4611f0ab7a |
test(files): give the e2e harness the column the receipt link adds
listFiles projects linked_budget_item_ids now, so the harness table it selects from needs budget_item_id or every list call in that file answers 500. Same gap the collab harness had. |
||
|
|
239856f35c |
fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written to their personal settings row. The CSP is built from something else: bootstrap reads the ADMIN default and puts that origin into `connect-src`. So filling the field in was the one path the product offered, and it could not work — the route calculator happily built its URLs against the typed origin and the browser refused every one of them with a bare TypeError, not a status, so no fallback and no retry could see it. Day-plan routes, transport legs, the mobile day route, Studio's road route and the whole road-trip rail stopped routing, app-wide, with a generic "route could not be calculated". Nothing in the UI could repair it except emptying the field again, and there was no admin field to set the value that would have made the origin allowed. The repo has decided this shape once already: `llm_base_url` is admin-only because it names where the server sends its own requests and only whoever runs the instance can judge what is reachable. This is the same class for a different reason, so it joins the same guard, which is now named for what it actually covers. Clearing stays open to everyone, so a row written before the rule cleans itself up rather than blocking the save. The field moves to the admin's default-user settings, next to the tile URL and the Carto key, where a value takes effect. |
||
|
|
65be8db122 |
test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No controller test, no e2e, and no MCP test — so the guard chain, the trip scoping on every route, the cross-trip track check and the day_edit parity were all unasserted, and the whole domain sat in the 80 catch-all where it could shed twenty points against the headroom of the domains beside it. The e2e is the one that earns its place. `@RequireAddon` is metadata and does nothing on its own, and AddonGuard is not global, so the ordering the controller's own comment calls load-bearing is exactly the kind of thing a later change removes without noticing — it had already happened once. Deleting the guard now turns three cases red, which I checked by deleting it. The MCP tools had never executed at all: the shared harness seeds the addon disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run against the same checks the REST routes make, including the demo account and a member without day_edit, because the repo requires the two surfaces to move together. Two client modules that had no test get one: the overlay builder, where two decisions live that are not cosmetic — which offered road is drawn as the one you are on, and that a label hangs where its own route is unlike every other, rather than at a midpoint that can fall on a stretch two routes share — and the service marker, where the thing worth pinning is that an ordinary place gets nothing back, and that the anchor arithmetic includes the border. The domain carries its own coverage floor now instead of hiding in the catch-all. Pinned under the measured figure on purpose; the exact ratchet gets regenerated with the repo's own script after a full run. Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every other domain's tables are on. Without that a via written by one case was still there for the next one, which is how the first version of the MCP suite passed an assertion it should have failed. |
||
|
|
7b5c8c1fae |
test(places): teach the e2e tables the stop_type column
Three e2e suites build their own trimmed places table with a plain CREATE TABLE rather than going through the schema and the migrations, so the column this branch added never reached them. Every assignment, day and place case that touched a place failed on "no such column: stop_type", thirteen in total, and none of it showed because the checks do not run on a draft. |
||
|
|
b3d69aa760 |
feat(places): record which search result was picked, on this instance only
The corpus behind "is our own index good enough to replace Nominatim". When somebody searches and then picks a result, the client posts the query, the rank and the pick. Nothing else: no user, no trip, no session, and coordinates only to three decimals. Deliberately decoupled from the search endpoint. Correlating a response with a later pick would mean handing out a ticket on POST /api/maps/search and threading it through all fifteen call sites; the client already holds everything the row needs at the moment of the pick. Evaluation happens offline afterwards: export the corpus, run it against a candidate index, count how often the pick came back in the top five. That is why collection can start before such an index exists, which is the whole point, because the corpus needs weeks of real use and the index needs a server, and those two waits should not be serial. Off unless an admin switches it on, and it reads === 'true' rather than !== 'false'. The three older places switches fail open because a migration backfilled a row for installs already using them; nothing uses this one, so an instance that upgrades and never visits the admin panel logs nothing. Reading is admin only, retention is 180 days, and the switch state is on the unauthenticated app-config endpoint on purpose: whether an instance records what its users search for is something a visitor may know before logging in. |
||
|
|
2a0ceb2687 |
feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two Osaka subway stops finds nothing — the stops are not in the feed at all. Add Google as an optional backend behind an admin setting, reusing the install's existing Places key, and fall back to Transitous whenever no key resolves. Uses the Routes API rather than the legacy Directions endpoint the request named: every other Google call here already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud projects created after March 2025. Google returns walking as turn-by-turn navigation steps where MOTIS returns one leg per walking segment, so consecutive walk steps are coalesced into a single leg — otherwise a finely-sliced walk pushed a real journey past the 20-leg cap and the whole itinerary was dropped. Both responses now name the backend that answered. The fallback is silent by design, which left an empty result ambiguous between "no data here" and "the provider you picked never ran", and the admin panel warns when Google is selected but no key resolves — or when only the admin's own key is set, which serves them and nobody else. Cost shaped the implementation, since Google bills where Transitous does not: both field masks are cut to the cheapest SKU tier that still answers the question, cache TTLs are longer than the Transitous path's, and route alternatives ride along inside the already-billed request. Addresses #1699 |
||
|
|
9ed88adf34 |
fix(budget): an expense with no payer creates no debt
An expense saved with "No one paid yet" was still charging its split members in calculateSettlement while crediting nobody, so the ledger drifted off zero by the unpaid total. The greedy simplifier that turns balances into payments has no memory of which expense produced which debt, so it handed that phantom amount to whoever happened to be a creditor from an unrelated expense. In the report, two people each owed about 80 to someone who never paid that bill. Booking every payment it offered then left one member permanently short, next to an "everyone square" line. Both cost panels already promise the opposite: they label such a row "Unfinished, total only, not settled yet" and count it into the Outstanding amount card instead. The server now agrees with them. The guard deliberately does not also require a non-zero total, unlike the panels' label predicate. PUT /budget/:id/payers with an empty array zeroes total_price without re-applying it, which would otherwise leave a custom split debiting its members against no credit at all. The #1543 fixture and BUDGET-SVC-DB-004/005 were re-baselined: they pinned the old arithmetic through a deliberately payer-less 9 000 RUB item. The wiki said the old behaviour was intended, so it is updated too. Closes #2225 |
||
|
|
6cbefd23e8 |
feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them, the ones no entry holds, or one entry at a time, with the day and the place under each name. An entry card in the Entries tab reaches its own pictures the same way. Pictures can now come in without leaving Studio, into the entry the filter names or into the gallery, from the panel or dropped straight onto the page, and a photo element can fill its page or the whole spread in one press. Stops can be taken out of the arithmetic. A journal entry carries a stats_excluded flag; an entry that has it is off the route, out of the distance, out of the countries and not a step, while staying in the journal. Studio lists every stop in the travel panel with a switch, the entry editor carries the same switch on desktop and phone, and the entry card says when a day is off the route. |
||
|
|
c21e86e3bd | feat(planner): surface and edit day-assignment notes across desktop, mobile and pdf (#2163) | ||
|
|
d1aa573167 | fix(budget): seed edit fields with two decimals and allow signed amounts for refunds (#2175, #2176) | ||
|
|
c59425947a | fix(server): unicode-safe content-disposition for gpx, ics, feed and wallet downloads (#2165) | ||
|
|
4704bf5903 | fix(auth): fail fast on passkey origins outside the relying party scope (#2147) | ||
|
|
d5a3427cbb | fix(packing): accept weight, bag and quantity on create across REST, MCP and plugin RPC (#2154) | ||
|
|
fef5c39581 | feat(admin): photo providers require the journey addon | ||
|
|
9e0fb19e79 |
fix(studio): keep the auto layout inside the book contract
Auto layout wrote journey data straight into the document without holding it to the caps the contract sets, and the save path sends the document unvalidated - so the route refused the whole body with a 400 and the editor reported "couldn't save" for the rest of the session, because autosave kept offering the same rejected book. The everyday way in was the itinerary list: thirteen stops a page over two pages is eighty elements on a spread capped at sixty, so a run of twenty bare stops was unsavable and a longer one put two spreads over. Sixty was below what this app's own layouts draw, which makes it a bug with a constant in front of it rather than a limit, so the cap goes to ninety and the page keeps the design somebody chose. It stays a serialisation limit: the largest document the contract now admits is under four megabytes against the book route's eight. A long journal entry, a long place name, a journey past eighty countries and a long title each did the same thing on their own, and a bound story did it on open without anyone touching Auto layout. The caps become named exports on the contract and every producer builds against them, so the two cannot drift apart again - which is also why the stations chunk is derived from the cap and the salvage fixture counts off it. Fixes #2085 |
||
|
|
5a7b8d75aa |
feat(api): add GET /api/v1/stats for dashboard widgets
Homepage's customapi widget and everything shaped like it want a handful of numbers from one request; /api/v1/trips hands out the rows, but a widget maps fields, it cannot aggregate a list. The figures come from AtlasService.getTravelStats, the same call behind the dashboard's passport card, rather than from a second count. Visited countries carries years of rules — countries reached only by a flight, layovers that do not count, countries hidden by hand — and a second implementation would disagree with the card next to it within a release. That is also why the controller sits in atlas/ and not in public-api/: PublicApiModule stays a leaf, so the route comes to the data instead. TravelStatsController next door already does this for /api/auth/travel-stats. ApiTokenGuard is provided locally rather than imported with PublicApiModule — @UseGuards instantiates a guard in the declaring controller's module, so an export would still leave its TokenService unresolvable here. last_trip means the most recently started trip, not the most recently created one, and is null while every trip is still ahead. Closes #1367 |
||
|
|
1e1a80c199 |
feat(api): expose shortlisted places, detached bookings and the bucket list
Reading the surface back against real data turned up three things it dropped without saying so. Places with no day were invisible, because the payload only reached places through day_assignments. On a live instance that is half of them, and they are the ones carrying a coordinate and no claim about when — exactly what an integration can act on. They come back under `unplanned_places`, minus the hotel, which is already reported as an accommodation. Bookings with no day were dropped by the day filter. reservations.day_id is nullable and deleting a day detaches its bookings rather than removing them, so this is not a hypothetical. The bucket list had no endpoint at all. It hangs off the user rather than a trip, so it gets its own, and it answers whether or not the Atlas addon is switched on: the addon decides what TREK shows, not what a key can read. Also fixes ?include=notes returning the bare trip: places, notes and reservations are reported on days, so asking for one now implies days instead of quietly answering with nothing. The module stays a leaf. Reaching for AtlasService pulled AuthModule and the storage registry in behind it and broke the surface's own boot, so the one SELECT lives here like every other query in the file. |
||
|
|
4b120d39c8 |
feat(api): separate API keys from MCP tokens, add travellers
An MCP token drives every assistant tool; an API key reads trips over HTTP. Handing the same credential to a third-party integration gives it the whole MCP surface, so the two are now separate kinds on `mcp_tokens` and the kind sits in the WHERE clause — a token of the wrong kind is indistinguishable from one that does not exist. Also adds `travellers` to the trip payload: display name and an owner flag, no ids and no email addresses. An integration key reads its owner's itinerary; it is not a way to enumerate the people around them. Documents the surface in the wiki, since integrators need something to read that is not the source. |
||
|
|
9cddf683a5 |
feat(api): rate-limit /api/v1 and report when a trip last changed
Two gaps from the first pass, both about a client that polls. Rate limiting is keyed by user rather than by IP. A self-hosted integration and its owner's browser routinely leave the same address, and an IP bucket would let one starve the other; 120 requests a minute is generous for a sync surface and still bounded, so a runaway poll degrades its own integration instead of the instance. updated_at now ships with every trip, and its docblock says plainly what it is not: a content timestamp. It moves when the trip's own fields change — title, dates, currency, cover — and stays put when a place, a day or a note is edited, because the child tables carry no updated_at of their own. That also rules out the ?since= filter this was meant to enable: it would silently hide every trip whose itinerary changed, which is worse than no filter at all. Making it honest needs a schema change, not an API field, so the field is documented for what it is and the filter is left out. |
||
|
|
fd6381c814 |
feat(api): add a versioned read-only public API for integrations
TREK's REST surface is internal: it answers a session cookie, carries no version, and changes with the client. That is fine for our own frontend and useless for anyone building against us — the Dawarich integration people have been asking for since April needs something it can rely on not moving. So this adds a small one rather than opening the existing 549 endpoints: GET /api/v1/trips the trips the caller can reach GET /api/v1/trips/:id one trip, ?include= picks the sections Auth reuses the trek_ tokens users already manage under Settings, via Bearer or X-API-Key. No new credential type, no new table — the guard calls the same hash lookup the MCP transport does. It deliberately refuses a session JWT and, for now, OAuth bearers: a credential whose scopes this surface does not yet interpret is worse than one it rejects outright. Access is decided per trip against canAccessTrip, the same owner-or-member predicate as everywhere else, and a trip the caller may not read answers 404 identically to one that does not exist — a 403 would confirm the id is real and turn the endpoint into a way to count someone else's trips. The payload is not the rows. Ids, foreign keys and order_index stay inside; accommodation day ids resolve to ISO dates, places arrive in planned order, and every dated thing carries its date so a consumer can join on days rather than on ids it has no way to look up. |
||
|
|
15588d20c9 |
fix(feeds): keep a staged booking out of the public feed and the shared trip
The ICS feed is public with a token that never expires and prints the confirmation number into the DESCRIPTION, and the shared-trip payload does a SELECT * over reservations with share_bookings on by default. Neither filters anything, so whatever lands on a trip is published the moment it is written. That is fine while a person types every booking in, and it stops being fine as soon as something writes on their behalf. reservations.ingest_state is that gate. It defaults to live, SQLite backfills every existing row on the ALTER, and no current writer names the column, so every booking visible today stays visible. Nothing writes staged yet. The predicate takes its table alias because share.service.ts queried reservations without one. A stay is withheld only when bookings point at it and all of them are staged: a hand-added hotel has no booking at all and has to keep showing up. |
||
|
|
42ba5d4849 |
fix(mcp): keep the categories module out of the auth graph
The admin gate on the three new category tools reached for AuthService, which drags the storage registry in behind it, and the categories e2e builds a container around one module against a two-table database. It stopped booting. budget/ already solved this: isDemoUserId reads RuntimeEnvService and the users table directly, and its module comments say why AuthModule is absent. Categories now does the same, which also takes eight hand-built collaborators out of its unit test. The e2e gained RealtimeModule, which is global in the app graph but not in a partial container, the way days.e2e.test.ts already imports it. |
||
|
|
521e52c853 |
fix(collab): harden the link preview fetcher (#2058)
The preview endpoint takes a URL from whoever is typing in a trip chat and fetches it from the server. Trip access was added to it earlier; this is the rest of what that route needed. The SSRF guard had a hole under it. `isAlwaysBlocked` knew `::1` and `0.0.0.0` but not the IPv6 unspecified address: `::` starts with a colon, so the `0.` check never saw it, and connecting to it lands on loopback. `http://[::]:9200/` therefore passed the guard and came back with the target's title and og: tags scraped out of the body — an exfiltrating read of anything listening locally, from any account. `[::0]`, `[0:0:0:0:0:0:0:0]` and `[::ffff:0:0]` are the same address in other spellings and behaved the same way. The two `::ffff:`-prefix regexes had the same shape of gap: they matched the dotted spelling of loopback and link-local, so `::ffff:7f00:1` walked past them. Both checks now decide on the expanded hextets, which covers every spelling at once, and fe80::/10 is read as ten bits rather than as the four characters 'fe80'. This is in ssrfGuard, so every caller gets it, not just collab. The scrape could be turned into a denial of service. Each og: regex had two unbounded `[^>]*` runs separated by a literal, so a document with no `>` in it made the engine rescan the remainder from every `<meta` it passed — quadratic, and the byte cap admits half a megabyte. Measured on this build: 240KB of '<meta ' took 58 seconds, and Node has one thread, so that is the entire server, health check included, on one request. Bounding the runs at 512 characters makes it linear (540KB now takes 1.4s); no real attribute list comes close to that. The five-second timeout only covered the headers — `clearTimeout` ran before the body was read — so a server that answered fast and then dripped one byte every few seconds held the handler, the socket and the per-request undici agent open indefinitely. The byte cap counts bytes and at that rate would never reach one. `AbortSignal.timeout` covers both halves, the agent is closed in a `finally`, and the three bail-outs that used to return without touching the body now discard it, which is what cappedFetch's helper is there for. The rest is smaller. Nothing metered the fetches, so an account with a trip of its own could drive arbitrary outbound requests in a loop; there is a budget of sixty new fetches a minute per user now, charged per outbound fetch rather than per request. Previews are cached for ten minutes and simultaneous askers for one URL share a single fetch, so the client's habit of requesting a preview per rendered message — up to a hundred at once, with no debounce — costs one fetch per new link rather than one per render. Refusals answer with one constant reason instead of the guard's own: it distinguishes "could not resolve" from "private address", which together map out the server's internal DNS for anyone willing to guess hostnames. A body whose declared type is not markup is dropped rather than combed for og: tags, and an og:image that is not an http(s) URL is not passed to the client, which puts it straight into an <img src>. Deliberately no `@RequirePermission` on the route: the client asks for a preview while rendering a message or a note, never while composing one, so it belongs with the GET siblings. Requiring collab_edit would blank every preview for members of a trip whose owner narrowed that right while leaving them able to read the chat, and it would not stop the abuse it looks like it addresses — the permission check passes for a trip's owner at every level, and anyone can create a trip. |
||
|
|
4bfb277423 |
fix(journey,files): close the cross-trip and role holes on the sharing surface
Files: two :id handlers took the file id from the URL and never resolved it against :tripId. Membership of any trip was enough to read another trip's file links (with the joined reservation titles) or to delete one. Both now resolve the file the way every sibling handler already does. Journey: a contributor with role 'viewer' - blocked from every other write in the domain - could overwrite or delete the Studio book, and could read the share token, which is an anonymous credential that outlives their contributor status. The book writes take canEdit now and the token read takes the owner check its own create and delete already use. Also: the share-link MCP tool reset every flag to permissive defaults when it was called to refresh a link, so a journey deliberately shared without its gallery or map quietly started publishing both; setShareLink dropped newest_first entirely; the public routes ignored the Journey addon switch; and photo uploads committed to storage before the access check, leaking the objects on the 403 path. Trips: DELETE answered 403 to someone with no access at all, which turns sequential ids into an existence oracle. A member who simply lacks trip_delete still gets 403. |
||
|
|
3f8a7a1ea9 |
fix(feeds): keep subscribable calendars working under default-deny
The ICS feed routes are documented as public - the secret token in the URL is the credential - but nothing in the code said so, and the guard this release makes global is default-deny. Every calendar client subscribed to /api/feed/trip/<token>.ics would have started getting 401 on upgrade, and a calendar app that stops fetching just goes quiet. The e2e suite asserted anonymous access the whole time. It booted [DatabaseModule, RealtimeModule, FeedsModule], and the APP_GUARDs live in AppModule, so it was asserting against an app that had no opinion on anonymity. It boots the real guard stack now, and fails without the fix. The boot ratchet could not have caught this either: it only looked at routes carrying @Public(), and GlobalAuthGuard stands down for ANY declared chain - so a chain of guards that refuse a caller but never resolve one answers strangers, invisibly. Split the inventory in two and write that half down as well. It turns up two routes nobody had listed: the passkey login ceremony, which genuinely runs before a session exists. |
||
|
|
305b113d5f |
Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver * feat(storage): MirrorDriver composite backend * feat(server): add TREK_UPLOADS_DIR env var for the uploads root * feat(storage): settings-backed registry, StorageService facade, module wiring * test(storage): pin src/nest/storage coverage thresholds * feat(storage): multer upload options factory * refactor(files): spool trip-file uploads through storage * refactor(collab): spool note attachments through storage; normalize files/ prefix * refactor(journey): spool journey media uploads through storage * refactor(trips): spool trip cover upload through storage * refactor(places): spool place-image and collection uploads through storage * refactor(auth): spool avatar upload through storage * fix(server): consolidate allowed_file_types onto AllowedFileTypesService * fix(storage): default defParamCharset to utf8 in the upload factory * test(db): re-point the trailing-migration guard at the trip_files prefix strip * fix(storage): age-gate the boot spool sweep * test(files): key FILE-P02 residue checks on upload size * test(storage): raise src/nest/storage coverage pin * refactor(platform): serve /uploads routes through storage * refactor(files): serve trip-file downloads through storage * refactor(memories): serve photo bytes through storage * refactor(maps): stream place-photo bytes through storage * refactor(share): stream shared place-photo bytes through storage * refactor(journey): serve public journey assets through storage * fix(share): answer place-photo cache miss with 204 like the maps proxy * test(server): close serving-swap gaps surfaced by the full tier * test(storage): raise src/nest/storage branches pin * refactor(place-photos): route place-photo cache through storage * refactor(places): reclaim place images and collection covers through storage * refactor(trips): trip covers through storage * refactor(auth): avatar reclaim through storage * refactor(collab): note-file deletion through storage * refactor(files): trash deletion through storage * refactor(memories): trek photo cache internals through storage * refactor(memories): thumbnail service category-addressed * refactor(memories): immich upload reads through storage; drop uploads-root * refactor(journey): photo deletion through storage * fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep * feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim * test(server): close slice-4 gaps surfaced by the full tier * test(coverage): raise slice-4 domain pins * refactor(backup): list, existence and delete through storage * refactor(backup): download through storage sendToResponse * refactor(backup): create backup through per-category storage enumeration * refactor(backup): restore and upload-restore through storage * refactor(backup): auto-backup retention through storage * refactor(server): boot dir-creation to driver init * test(server): close slice-5 gaps surfaced by the coverage tier * test(coverage): raise slice-5 backup pin * test(server): resolve async backupFileExists mocks for the tests typecheck * test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract * feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule * feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines * refactor(server): type the aws-lite S3 client via s3-types reference * feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination * feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline * fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup * feat(server): env-declared s3-main storage backend; settings-declared s3 rejected * test(server): env-gated S3 driver contract suite against MinIO * fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect * fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost * ci(server): MinIO-backed S3 driver contract job * ci(server): align s3-contract shim comment and MinIO bind with references * docs: S3 storage backend configuration and off-box backup mirroring * docs: restore README formatting, keep S3 additions only * ci(server): make live-MinIO contract runs manual-only (AIStor licensing) MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header. * chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case * expose admin ui * feat(shared): storage backend type registry, wire schemas, category relocation * feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview() * feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path * feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact) * chore(server): drop storage-secrets import that only Task 8's probe helper will use * feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed * feat(server): registry snapshot() — effective storage config with provenance * feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits * feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually * test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes * test(server): give e2e harness DBs the app_settings table the storage registry reads at boot * test(coverage): raise pins after the storage admin-config slice * fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes * test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401 * feat(i18n): storage admin domain + tab label in every locale * feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend * feat(client): storage admin draft-document helpers + relative-time util * feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner * feat(client): admin storage panel — backends list, category map, health strip, one-PUT save * feat(client): storage tab on the admin page * feat(client): storage admin panel and section on the phone shell * fix(client): block renaming a storage backend onto an existing name * feat(server): allow mirrors on any storage category — lift the backups-only rule * feat(i18n): replicas-on-primary mirror strings in every locale * feat(client): mirror fold/synthesize model helpers for replicas-on-primary * feat(client): BackendForm mirror-targets composer; raw mirror type hidden * feat(client): desktop storage panel folds mirrors into their primaries * feat(client): phone storage panel folds mirrors into their primaries * fix(client): strip removed backends from remaining mirror replicas — no phantom refs * feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam * feat(i18n): descriptive storage category names and descriptions in every locale * feat(client): descriptive category names, descriptions and id badges in the storage panel * docs(storage): fix stale nine-category comments after the eight-category shrink * docs: storage configuration via the admin UI and seed file — README + compose * docs(wiki): Admin Storage page and storage cross-links * docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier * refactor(server): drop the storage env-var tombstones — the variables never shipped * docs: storage config is simply new in v4 — drop the removed-variables story * feat(shared): backfill-status and usage wire schemas on the storage admin state * feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up * feat(server): one-at-a-time storage backfill jobs + the storage events bridge * feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt * feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration * feat(i18n): replica-failure notification text in every locale * feat(server): storage backfill and stats-refresh admin endpoints * feat(client): adminApi backfill and stats-refresh endpoints * feat(i18n): storage sync and usage strings in every locale * feat(client): desktop storage panel — sync now, usage display, backfill polling * feat(client): phone storage panel — sync now, usage display, backfill polling * fix(client): per-category usage line on the phone storage panel * docs: sync now, usage stats and replica-failure notifications * test(server): admin scope now includes replica_failure, not just version_available Commit |