Commit Graph

98 Commits

Author SHA1 Message Date
Maurice b36103d2a9 Document where place pictures and descriptions come from
NOTICE knew about geoBoundaries, OSM and OurAirports but not about Wikimedia,
Wikipedia or Google Places, all of which TREK has been calling for a while.

Also fixes two dead paths in the place contract - it still pointed at
server/src/routes/places.ts and services/placeService.ts, both long gone.
2026-08-10 12:24:15 +02:00
Maurice f2368f86cf Add an admin switch for place enrichment
Reads fail-open, unlike the three older places_* switches. Those read
=== 'true', which is why migration 185 had to backfill a row on every existing
install; there is nothing here that warrants repeating that.

The three hand-rolled toggles next to it move to ToggleSwitch while I am in
there - they were bare buttons with an inline transform and no aria-pressed or
label, so a screen reader could not tell their state.
2026-08-10 12:24:15 +02:00
Maurice 4cd50e6ed3 Add a contract for place enrichment 2026-08-10 12:24:15 +02:00
Maurice 7d0e90eb47 Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
2026-08-09 00:52:05 +02:00
Maurice 6bf2e0708d feat(client): add the Startup section to General settings
Desktop and mobile both get it, in the style of the file they live in -
the On/Off button pairs on desktop, segments and a picker sheet on mobile.
The tab picker only appears once "Active trip" is chosen, since there is
nothing for it to mean otherwise.

Labels come from the same keys the planner's tab bar uses, so the picker
can never name a tab differently from the tab itself.
2026-08-08 23:01:40 +02:00
Maurice 99ee307034 feat(server): expose the active trip as its own endpoint
GET /api/trips/active answers with the trip a user means by "my trip"
right now: the one running today, else the next one starting, else the
most recently started. Archived trips never qualify.

That order is the dashboard hero's (sortTrips), on purpose - the startup
redirect this is for has to land on the same trip the dashboard features,
or the two would disagree about which trip is yours.

Kept apart from list() because it runs on a first paint: four columns of
one row instead of every trip with its per-trip day and place counts.
2026-08-08 23:01:40 +02:00
Maurice 97f524cdfd feat(server): give the memories endpoints their Zod contracts
The last nine memories entries in the body-contract allow list are gone: three
each on the immich, synology and unified controllers. They read @Body() as
Record<string, unknown> and coerced by hand.

The schemas are deliberately permissive and loose, and that is the point rather
than a shortcut. Paging arrives as `2` or `'2'` because the controller does
`Number(page) || 1`; synology_skip_ssl arrives as a boolean or the string
'true'; auto_upload is accepted in any shape because only a real boolean is
acted on. A schema that described the ideal body instead of the real one would
turn working clients into 400s, and parity outranks tightening. Narrowing them
is a separate, client-visible change.

Loose objects for the same reason: a strict object strips unknown keys, and
`selections[]` goes straight to the services as `Selection[]` - stripping there
would be a silent data loss the day a provider adds a field.

This unblocks be-memories-fold, which could not move a controller whose body had
no contract to move with it.
2026-08-08 12:43:29 +02:00
Maurice cf62567c48 fix(map): stop untrusted values reaching the marker HTML (#1820)
* fix(map): drop the casing layer before its source on teardown

Tearing down the reservation overlay logged an error on every map unmount:

  Source "trek-reservations" cannot be removed while layer
  "trek-reservations-lines-transit-casing" is using it.

The casing layer arrived with the transit paths in 3.2.0 but was never added to
destroy(), which only removed the main line layer. The try/catch around it gave
no protection either — the engine reports this by firing an error event rather
than throwing — so the source was silently left behind on the style.

The layer id is a named constant now, so the place that creates it and the place
that removes it can no longer drift apart.

The lifecycle test grew the assertion that would have caught it: the fake map
now refuses to remove a source that a layer still references, the same way the
real one does. Verified against the unfixed code — two tests fail without the
change.

* fix(map): stop untrusted values reaching the marker HTML

Both map renderers build their marker HTML as a string and hand it to
L.divIcon / innerHTML. Two of the interpolations carry values a user controls,
and neither was escaped.

place.image_url is the worse of the two, because it is not admin-only: any trip
member with edit rights can set it. placeUpdateRequestSchema is a plain record
of unknown values, and the length guard in places.controller covers name,
description, address and notes but not image_url. The renderers do check the
value, but only with startsWith('/uploads/') — a payload like

  /uploads/x" onerror="…" y="

satisfies that and then breaks out of the src="…" attribute.

category.color is the same shape one line up, reachable by an admin. It also
reaches SharedTripPage, which builds its own divIcon and answers without a
guard, so that payload lands in front of people who have no account here.

Colours are allow-listed rather than escaped. Escaping stops the attribute
breakout but leaves a working CSS value behind, and `url(https://…)` in a
background is a tracking pixel. Anything that is not #rgb/#rrggbb falls back.
image_url is a URL, not an enum, so that one is escaped; the prefix check stays.

Server side, the category contract now demands a hex colour, and the endpoints
validate through DTO classes instead of @Body('color') — a property read carries
no metatype, so the global pipe never saw it. That retires two entries from the
body-contract allow list. It replaces the hand-rolled 400 for a missing name
with the pipe's envelope, the same trade the places and trips migrations made.

The response schema deliberately still accepts any string: the write path was
open for a long time, so tightening it would make an instance fail on its own
stored rows. The client allow-list is what covers those.

Verified against the unpatched renderer — the three new cases fail without it.

* perf(client): serialize the map icons without react-dom/server

Six modules imported react-dom/server statically to turn a lucide icon into an
SVG string. That put Fizz — roughly 190 kB raw, 57 kB gzip — into a chunk three
lazy routes share, the Leaflet renderer among them, which is the default. For
output that is always a single <svg> with a flat list of shape children.

  mapViewport chunk   189,532 -> 3,710 B
  Tooltip chunk       194,333 -> 1,970 + 4,860 B
  precache total      17,856 -> 17,675 KiB

utils/iconMarkup.ts serializes React elements directly. It is generic over
elements rather than over lucide's icon tables on purpose: iconNode is closed
over inside createLucideIcon and never exported, so calling the forwardRef
render function is what survives a lucide upgrade.

Byte-compatibility is checked, not asserted. iconMarkup.parity.test.ts diffs the
output against real Fizz for every exported icon in three prop shapes — the test
file is the one place still allowed to import react-dom/server, and it never
reaches a bundle. Two traps it covers: viewBox must not be kebab-cased into
view-box, and Fizz emits <path …></path> rather than a self-closing tag.

TripPDF loses its lazy renderer entirely — ensureRenderer, the module-level
handle and the two "not ready yet" guards that returned an empty string. There
is nothing left to load.

An eslint rule keeps the import from creeping back. Without it the regression is
invisible: the build stays green, the app keeps working, and the chunk quietly
grows by 190 kB again.
2026-08-07 13:43:38 +02:00
Maurice 39b6d9f786 feat(client): catch render errors instead of blanking the page (#1805)
* feat(client): catch render errors instead of blanking the page

There were no error boundaries anywhere, so one throw during render unmounted the
whole tree and left the user staring at a white page with no way back.

ErrorBoundary holds the state in a class — getDerivedStateFromError has no hook
equivalent — and renders a function component for the visible half, so the panel
can translate normally. TranslationContext isn't exported, so contextType was not
an option anyway.

Two things it does beyond showing a message. A chunk that 404s after a deploy gets
one reload rather than a retry button: React.lazy caches the rejected promise, so
retrying re-throws immediately. And the reload happens once per session, or a
genuinely missing chunk would spin the tab.

The global handlers in main.tsx cover what no boundary reaches — event handlers
and async code. They filter axios errors: the interceptor already redirects on
401/403 and there are 600+ toast.error call sites, so reporting again would mean
double toasts and, for the redirects, loops.

* feat(client): isolate routes, widgets and the shell chrome

The route boundary goes on the single line where all 16 protected routes render
their content, inside MobileShell rather than around it: the navigation survives,
so a broken page is something the user can walk away from instead of a dead end.
The mobile --m-* tokens live on the shell's .m-root too, so a fallback outside it
would render invisible.

key={location.pathname}, not resetKeys — ProtectedRoute is the same component at
the same position for every protected route, so otherwise a failure could follow
the user across a navigation.

The public routes render outside ProtectedRoute and were therefore uncovered,
including /login (where people land when everything else failed) and the two
anonymous share pages. PublicRoute wraps those seven.

The five global widgets and the shell chrome get fallback={null} each: a broken
widget should disappear quietly, and if BottomNav dies the user loses the very
thing they need to leave the route.

* feat(client): isolate the GL maps and the plugin frame

Both map wrappers put the boundary outside their Suspense, not inside: Suspense
handles a pending promise, a rejected one throws straight past it. Falling back to
the Leaflet renderer keeps a usable map rather than showing an error card — the
same downgrade the Suspense fallback already does while loading.

PluginFrame is third-party code rendering in our tree; a throw there should cost
the plugin, not the page. resetKeys={[pluginId]} so switching plugins clears it.

The locale loader had no .catch (TranslationContext.tsx). After a deploy the chunk
can be gone, which produced an unhandled rejection and left the UI silently on the
previous language. It now keeps the strings it has and says so in the console.
2026-08-06 23:42:57 +02:00
Renzo Beux 3ec4b33dbb Add "use my current location" button to Journey entry editor
- One-shot getCurrentPositionOnce() helper in useGeolocation with typed
  error codes (unsupported, insecure context, permission denied, timeout)
- Button inside the entry editor Location field fills coordinates from the
  device and reverse-geocodes the place name via /api/maps/reverse,
  falling back to formatted coordinates when no name is available
- New journey.editor.* i18n keys in all 23 locales
- Unit tests for the helper and component tests for the button flow
2026-08-05 15:28:20 +02:00
Renzo Beux c89d2bf3a1 Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can
already announce "Kanazawa begins" in the day plan, but it can't make
day 12 look like it belongs to Kanazawa.

dayScheduleProvider can't cover this: it renders a fixed row, and it's
capped at 60 items per provider, so one item per day would tint the
first 60 days of a six-month trip and silently stop. This hook is
bounded by day count instead.

GET /api/day-tints/:tripId returns per-day tones for three regions of a
day card - the number badge (also the mobile day chip), the header row,
and the expanded activity list. `tone` is a shorthand filling every
region a contribution doesn't name; unnamed regions render as they do
today. Tones only, never hex, so the host can pick the alpha per theme
and per region.

One contribution per day, resolved whole: first entry wins within a
provider, first granted provider wins across plugins.
2026-08-05 15:28:15 +02:00
Konstantinos Thermos 17dd6b7caa feat(map): toggle the trip map between default and satellite tiles
Add a corner button on the Leaflet trip map that switches the base layer
between the default street tiles and a tokenless satellite view (ESRI
World Imagery). The choice is a per-user setting (map_base_layer) that
persists and applies instantly, including offline. GL-renderer satellite
styles are unchanged.

Closes #993
2026-08-05 15:13:13 +02:00
Maurice d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +02:00
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00
jubnl fb87202683 feat(server): validate auth request bodies
DTO ratchet for the auth domain (collections sibling-commit precedent):

- shared/src/auth/auth.schema.ts gains the six missing request schemas —
  mapsKey (nullable: the client clears with an explicit null), apiKeys and
  settings (per-key partials, nullable key values), appSettings (open
  record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
  lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
  (the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
  params typed (9 AuthController + 5 AuthPublicController), validated by
  the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
  (PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
  envelope on a shapeless login body; auth.controller.ts drops its
  duplicated avatarDir const in favour of the auth.helpers export.
2026-08-01 17:40:16 +02:00
jubnl d11b7bb054 feat(server): validate collections reorder and delete-many bodies 2026-08-01 13:54:36 +02:00
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00
Maurice dc4ccb2304 chore(i18n): add the PDF page break label 2026-07-29 21:32:39 +02:00
jubnl 4706112516 feat(server): adopt zod DTOs for the maps body contracts 2026-07-29 20:21:09 +02:00
Maurice 14bc41f2cf chore(i18n): drop the unused map template placeholder key
Left behind by the tile URL change (#1733): all four call sites use the
.select variant, the plain key had no consumer.
2026-07-29 15:14:21 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
jubnl a3c2cfa4b2 fix(shared): tighten WS payload schemas after the MCP drift fixes
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
2026-07-29 00:34:58 +02:00
jubnl c0baaeb898 feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
2026-07-29 00:15:56 +02:00
Maurice 2b8b55aa8f Translate the track colour strings 2026-07-28 07:25:36 +02:00
Maurice 1b5317a817 Give GPX tracks their own colour (#776)
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.

Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.

The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.

Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
2026-07-28 07:25:36 +02:00
jubnl e62628bf93 feat(server): adopt zod DTOs for the day and accommodation body contracts 2026-07-27 20:56:45 +02:00
jubnl c27fc99220 feat(server): adopt zod DTOs for the reservation body contracts
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.

reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.

AccommodationsController stays grandfathered until the dayService migration.
2026-07-27 19:45:41 +02:00
jubnl 29e89a5774 feat(server): adopt zod DTOs for the vacay body contracts 2026-07-27 18:39:47 +02:00
jubnl 470e4dc92c feat(shared): make collab note optional fields nullable in the request contracts 2026-07-27 00:27:06 +02:00
jubnl dd3c03ba0f feat(shared): add fileUploadRequestSchema for the files upload body 2026-07-26 23:19:04 +02:00
jubnl 41b51849b7 feat(server): adopt zod DTOs for the assignment body contracts 2026-07-26 20:34:06 +02:00
jubnl 9650ad4597 feat(server): adopt zod DTOs for the trip-invite body contracts
New tripInviteLinkCreateRequestSchema in @trek/shared (single source of
truth), a createZodDto wrapper on the controller's raw @Body(), and the
TripInviteLinkController.create entry dropped from the body-contract
allow-list (the boot ratchet enforces it).

expires_in_days accepts number | digits-only string | null | absent — the
string branch is digits-only so garbage like "7abc" is a 400 instead of
silently parseInt-ing to 7; the empty string (blank form input, meaning no
expiry) stays accepted. The e2e registers ZodValidationPipe and pins both
the bounded-expiry and 400 paths.
2026-07-26 18:21:18 +02:00
jubnl 3762d3306c feat(server): adopt zod DTOs for the day-note body contracts 2026-07-26 17:44:25 +02:00
jubnl 5da2abf4f9 feat(server): adopt zod DTOs for the packing body contracts 2026-07-26 16:55:53 +02:00
jubnl 8e6b3a424e style(shared): apply eslint --fix line wrapping to locale and schema files 2026-07-26 14:54:59 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
jubnl 45c1568e7c chore: correct user setting currency i18n 2026-07-25 17:02:32 +02:00
Maurice 39d4dc2675 feat(vacay): thin the mobile person card down to the number you act on
Used, carried over, comp days and remaining all fit a desktop sidebar; on a
phone they collapsed into four scraps of 9px text fighting for one row. Only
the balance is left, as a pill tinted by how much of it there is — the rest is
still a tap away in the entitlement view.

The toolbar chip says "Company" rather than "Company Holiday": it sits beside
two other buttons and the building icon already carries the meaning. The
allowance stepper reads "Days" instead of "days / year", reusing the label the
desktop entitlement tile has always used — the year is on the pill above it.
2026-07-25 14:49:38 +02:00
Maurice bccf4284c4 i18n(login): drop the keys the feature tiles used
login.features.* and login.selfHosted across all 23 locales — nothing renders
them any more.
2026-07-25 14:49:38 +02:00
Maurice 59128566d8 i18n(vacay): translate the leave-year and comp keys 2026-07-25 12:02:16 +02:00
Maurice 9d93ed95bf feat(vacay): read and write the leave year over its own window (#737)
Adds GET/PUT /addons/vacay/year-settings and makes the reads window-aware, so
the backend from the previous commit is actually reachable.

Entries and shared calendars now load over the caller's window instead of a
date prefix — a shifted year spans two calendar years and the prefix silently
dropped the second half. Reads are month-aligned (the grid draws month cards),
while entitlement stays day-exact; the two coincide for every window starting
on the 1st, which is all of calendar. deleteYear clears entries per author over
that author's period, and company holidays only over the range every member
shares, so one member deleting a period cannot reach into another's live one.
getStats reports the window it counted, and getOwnPlan seeds the period today
falls into rather than the calendar year.

A start day the month cannot have (Feb 29 from a hire date, a stored Feb 31)
is clamped to one every year has — otherwise the boundary string matches no
date and the whole window slides. Anniversary before a hire date is entered
resolves to the calendar year instead of borrowing a month a previous fiscal
setting left behind.

MCP reads pass their user through; without one the range stays the plain
calendar year, so plan-scoped callers are unchanged.
2026-07-25 12:02:16 +02:00
Maurice c475e5b04c feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
2026-07-25 12:02:16 +02:00
Maurice 8d7753c2e1 i18n(reservations): translate the traveler keys into all locales 2026-07-24 16:50:41 +02:00