Commit Graph

944 Commits

Author SHA1 Message Date
Maurice 870bd6131f feat(journey): draw the trips' GPX tracks on the journey map (#1911)
* feat(journey): serve the GPX tracks behind a journey

A journey has no trip of its own, but every entry records the trip it came from,
and a trip's routed geometries live on its places. So the tracks are derivable
without any new storage: GET /journeys/:id/tracks walks that link and returns the
geometries, dropping elevation nothing on the map reads and skipping anything
that is not a usable line.

* feat(journey): draw those tracks on the journey map

Both renderers, Leaflet and GL, plus the mobile map-timeline that shares them.
Solid and in the track's own colour with a white casing, so it reads as a
recorded route rather than as the dashed line that merely connects entries in
time order, and stays legible on satellite tiles.

* feat(journey): load the tracks alongside the journey

Fetched separately from the journey itself: most journeys have no tracks, and a
failed lookup should cost the map its lines rather than the whole page.

* docs(journey): document the GPX tracks on the map

* fix(journey): label tracks with the house map tooltip

bindTooltip's default box ignores the appearance tokens, so it sat there in
Leaflet grey next to markers already using .map-tooltip. Same class now, and an
unnamed track gets no tooltip at all instead of an empty one.
2026-08-12 15:14:50 +02:00
Maurice 767dc662b7 feat(atlas): search by place, and count cities that are actually cities (#1908)
* refactor(atlas): drop the last-trip tile from the stats bar

The row beside the continent counts is statistics; a single trip sitting in it
was neither, and the trip it named is already one click away in the country
detail below. Its two locale keys go with it, along with atlas.lastVisitLabel,
which nothing had rendered for a while.

* feat(atlas): resolve a coordinate to its country and region

Groundwork for searching the atlas by place: /locate answers with the admin1
region a point falls in, resolved against the same bundled polygons the visited
colouring uses, so the answer is always something the map can highlight. A
country without admin1 coverage resolves to a country and a null region, which
is a real answer rather than a failure.

* feat(atlas): search the map by place, not only by country

Typing a city geocodes it through the same /maps/search everything else uses and
offers the hits below the instant local country matches. Picking one flies there
and opens the dialog for the region it sits in, so Milan marks Lombardy without
the user knowing that is where Milan is.

The country list stays offline and instant; only the place lookup waits on the
network, debounced and sequence-guarded so a slow answer cannot overwrite a
newer query.

* fix(atlas): count cities instead of shop names

The visited-cities figure scanned the address left to right and took the first
part that was not a country and matched a latin-only pattern. The first part of
a geocoded address is the place itself, so a trip to Tokyo counted Shibuya Sky,
Shibuya Parco and Family Mart as three cities, while an address written in
Japanese, Cyrillic, Greek or Korean counted none at all.

Read from the administrative end instead, skipping the country, anything with
digits, and the region already resolved for that place in place_regions, which
is what separates the city from the region above it. On one real trip the count
went from 20 entries, nearly all of them streets and shops, to Paris, Shibuya,
Minato and three Berlin boroughs.

* i18n(atlas): translate the place-search heading

* docs(atlas): document place search and how cities are counted

* fix(atlas): stop the search panel closing itself while results arrive

The dropdown closed on mouseleave, which was survivable while it held only local
country matches and appeared complete in one go. Geocoded places land a moment
later and grow the list under a cursor already resting there, so the panel shut
itself seconds after opening. Closes on a click outside now, Escape unchanged.
2026-08-12 14:14:37 +02:00
Maurice 9ce6eed9ad feat(places): export a trip as GPX (#1907)
* feat(places): hand a trip back as GPX

TREK reads GPX but never wrote it, so a planned trip could not reach the offline
map or the handheld it was planned for. The importer already fixes the mapping:
a wpt is a place, a rte or trk is a place with route_geometry, so writing is that
reversed. Days have no import counterpart and become routes of their stops in
order, which is the part that puts a plan on a device.

XMLBuilder comes from the same fast-xml-parser the importer parses with, so
nothing new is pulled in.

* feat(dayplan): a GPX button beside PDF and ICS

Scopes are worded by what someone wants on their device rather than by the GPX
element: the whole trip, just the places for an offline map, just the days to
follow a plan. An empty trip says so instead of reporting a failed download.

* feat(mobile): GPX in the export sheet, which is no longer calendar-only

The sheet held the PDF export next to the calendar entries and was still called
Export Calendar; with GPX beside them the name was simply wrong.

* i18n(dayplan): translate the GPX export into all locales

* docs(map): document the GPX export

* fix(dayplan): drop the GPX tooltip and centre both export menus

The tooltip sat under the cursor exactly where the menu opens, so hovering the
button produced both at once. The label lives on as an aria-label. Both menus
now open centred under their button instead of hanging off its right edge.
2026-08-12 12:45:29 +02:00
Maurice b842d74a5d feat(collections): import a whole trip's places into a list (#1903)
* feat(collections): preview which of a trip's places a list already holds

The bulk import endpoint has been there since the select-mode copy, but nothing
calls it and it only reports duplicates after the fact. A dialog needs the verdict
up front, so /:id/importable/:tripId returns the trip's places with the duplicate
check the import itself applies, plus whether any day holds the place.

* feat(collections): import a whole trip's places into a list

Two steps: pick a trip, then tick its places. Places already on the list are
greyed out and unpickable, and the ones no day holds start selected — those are
the plans a trip left behind, which is the reason to import at all. Sorted so a
mostly-saved trip reads as the two that are left rather than a wall of grey.

* feat(collections): reach the trip import from the filter row and an empty list

* i18n(collections): translate the trip import into all locales

* fix(collections): stop the label dialog's buttons wrapping mid-word

Three actions in a max-w-sm footer wrapped into two lines each. The assign
button also painted white text on the accent, which disappears on a light one.

* docs(collections): document the trip import
2026-08-12 11:54:36 +02:00
Maurice 27ffd3ec68 fix(db): append the incoming leg mode migration so upgrades pick it up 2026-08-12 09:08:47 +02:00
Ida Rogalska f5e06e0dea test: outgoing and incoming leg modes persist independently 2026-08-12 09:08:47 +02:00
Ida Rogalska cedbe39b35 api: PUT /assignments/:id/transport gains direction (outgoing|incoming) 2026-08-12 09:08:47 +02:00
Ida Rogalska edb21b681f read: project incoming_leg_transport_mode via rowShape + getAssignmentWithPlace 2026-08-12 09:08:47 +02:00
Ida Rogalska bb6b946868 types: incoming_leg_transport_mode on assignment schema + rows 2026-08-12 09:08:47 +02:00
Ida Rogalska a17bfdb1a1 db: add incoming_leg_transport_mode to day_assignments 2026-08-12 09:08:47 +02:00
jubnl 7e7277beb0 fix(vacay): enforce weekend blocking in toggleEntry service
With block_weekends on, toggling a weekend day via MCP or the plugin RPC
still added the entry and burned allowance — the guard lived only in the
web UI (mcp-smoke-test I-02). toggleEntry now rejects inserts and
in-place conversions on blocked days with { error: 'weekend_blocked' }
(the votePoll pattern); removing an existing entry stays allowed so
stray weekend entries remain cleanable. The controller maps the code to
a 400, the MCP tool to an isError result.
2026-08-12 00:38:41 +02:00
jubnl 705c3c08dd fix(trips): remap cross-links and copy split rows when duplicating a trip
Copying a trip nulled budget->reservation and reservation->accommodation
links (TEXT accommodation_id vs number-keyed map) and dropped
budget_item_members/payers, assignment_participants, budget currency/
exchange_rate/expense_date/ticket_json and reservation url. All links now
remap to the copied rows; external_* sync identity is intentionally not
copied; the to-do reset is now documented (smoke-test I-01).
2026-08-12 00:22:32 +02:00
jubnl 92b9e7548e Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 23:26:36 +02:00
jubnl 04812702ec docs: close the bridge chapter 2026-08-11 23:25:22 +02:00
jubnl a01476f445 feat(mcp): audit MCP tool calls at the registry seam 2026-08-11 23:19:39 +02:00
jubnl 1239be4259 refactor(server): fold the _shared MCP helpers into an injectable guards provider; delete the last bridge 2026-08-11 23:11:34 +02:00
jubnl 954a3ca0a7 refactor(server): serve /mcp from a Nest controller; delete auth, audit, addons and oauth bridges 2026-08-11 23:01:19 +02:00
jubnl fac442086f refactor(server): providerize the OAuth SDK adapter behind the container 2026-08-11 22:44:20 +02:00
jubnl 1390bfbe06 refactor(server): move health, discovery and consent-COOP behind the container 2026-08-11 22:23:51 +02:00
Maurice ce37e8e97c Rich formatting and colours for notes (#1889)
* Rich formatting and colours for notes

Asked for in discussion #1629: notes should carry formatting, links and
colour, so a warning reads differently from a recommendation.

Notes have rendered Markdown for a long time. The problem was that the
only way to write it was a bare textarea, so unless you already knew the
syntax there was nothing to tell you it existed. Both note fields get a
formatting bar - bold, italic, strikethrough, code, link, lists, quote -
which edits Markdown rather than replacing it with a rich-text model, so
the stored value stays plain text that the PDF, the plugin API and every
existing note keep reading unchanged.

Links now open in their own tab. A bare anchor navigates the planner
away, which loses the open day and the map, and without rel=noopener the
opened page can navigate this one - notes are content other trip members
wrote.

Day notes get a colour from a short shared palette. It tints the card,
the border and the icon circle, and it reaches the PDF as the spine and
rule rather than a fill. The dialog is two columns now, with a live
preview of the card beside the picker, because a row of swatches says
nothing about what a tinted note looks like next to a place.

On the phone the thirty-two icon grid was most of the screen, so it is
folded behind the chosen icon, which doubles as the colour preview. The
detail field there got the same formatting bar and the same 2000
character ceiling as the desktop one - they write the same column.

* Fix the tests that pinned the old note caps, and rework the note actions

The e2e suite builds its own day_notes table by hand rather than going
through schema.ts, so it never got the new column and every note insert
came back 500. Three more tests wrote down the 250 character body cap
and the old create() arity.

While in there: the edit and delete buttons on a note card were a 10px
icon in a 14px box, side by side, and they took width away from the text
on every note whether you were hovering or not. They are stacked in a
capsule now, 30px targets with their own hover states, floating over the
right edge of the card only while the pointer is on it. The text keeps
the full width the rest of the time.

* Centre the day badge when there is no weather to stack under it

The badge is pinned to the top of the row because with weather it is a
tall stack that has to start there. Without a location there is no
weather block, so all that is left is a 26px circle floating above the
day title.

* Render the place subtitle in the places list, and drop the form hint

The row in the places sidebar printed the description raw while the same
line in the day plan renders it, so a place written with the formatting
bar read as _underscores_ in one list and italics in the other. Still
clamped to one line - it is a list entry, not the inspector.

The "Markdown supported" line under the notes field goes: the bar above
it already says so.
2026-08-11 22:14:12 +02:00
jubnl a5202f418b Merge remote-tracking branch 'origin/dev' into dev 2026-08-11 21:15:40 +02:00
jubnl f842073351 refactor(server): delete airtrail.bridge and notifications.instance via a core/pull split 2026-08-11 21:13:32 +02:00
jubnl c1819c7ab5 refactor(server): delete assignments.bridge via an assignments-domain module split 2026-08-11 20:58:37 +02:00
jubnl 079064fdcd refactor(server): delete trips.bridge via leaf membership reads and prompt relocation 2026-08-11 20:28:49 +02:00
Maurice 435b782c85 Add a note field to expenses (#1888)
Requested in discussion #1658: an expense carries an amount and a
category, and everything else about it ends up crammed into the title.

The column was already there. It just was not available, because the
costs UI stores an itemized receipt in it as TICKETJSON:{...}, so an
expense split by receipt could never carry a note - and saving one any
other way wrote note=null, which quietly wiped whatever had been typed
into the budget table. The receipt gets its own column and note goes
back to being text. Old clients that still send the blob as a note are
routed to the right column instead of clobbering the note.

On desktop the row now has fixed columns, so the amount does not drift
left when one row has a note and the next does not. The category moved
to the card edge as a tab like the mobile card does, which is what frees
the line the note sits on. Collapsed it is one line, and the same
element grows in place when opened.

The dialog is two columns now: what the expense was on the left, how it
is shared on the right, grouped into panels the way the trip dialog
does it.

On the phone the eleven category pills became a dropdown, which is where
the room for the note field came from, and a note on a card unfolds when
you tap it.
2026-08-11 18:13:07 +02:00
Maurice a39c31cb48 Hide permanently closed places from search (#1887)
A place that has shut down for good is never the answer to "where should
we go", so it should not be in the results at all.

Google knows this and says so in businessStatus, we just never asked for
the field. It goes into the field mask and CLOSED_PERMANENTLY drops out.
CLOSED_TEMPORARILY stays - a restaurant on holiday next month is still
worth planning around - and results without the field are parks and
viewpoints rather than businesses, so they stay too.

OSM states it a few different ways. Re-tagged objects (disused:amenity,
abandoned:shop) never matched the selectors to begin with, but plenty
keep their original tag and gain a marker instead, and those did come
back. Same for opening_hours=closed/off.
2026-08-11 17:04:09 +02:00
jubnl e1162c1c07 docs: bring the bridge docs in line
Roadmap census 14 -> 8 (5 seam-pinned, 3 verified-permanent), resources.ts DoD line closed, README in-container list and counts corrected. Includes the SystemNoticesService unit test the src/nest coverage ratchet demanded after the addonEnabled threading.
2026-08-11 17:00:05 +02:00
jubnl 1a945baebe refactor(server): shrink assignments.bridge to its two live exports 2026-08-11 16:46:52 +02:00
jubnl 8b7b91d814 refactor(server): let user-cleanup inject BudgetService and delete budget.bridge 2026-08-11 16:42:49 +02:00
jubnl 2b2c6462ec refactor(server): fold the in-container bridge edges 2026-08-11 16:32:02 +02:00
jubnl 0718bd1b0c refactor(server): shrink the seam bridges to their live exports 2026-08-11 16:23:46 +02:00
jubnl 7880eba6fb refactor(server): move the journey resources onto the registry and retire src/mcp/resources.ts 2026-08-11 16:15:12 +02:00
Maurice 8c8cc2a727 Require share_manage for the two public credentials (#1883)
Both advisories are the same mistake: a route that hands out a bearer token
treated trip membership as authorisation. Membership lets someone read the trip
while they are signed in. It does not let them mint a URL that reads it without
an account, keeps working after they are removed, and can be rotated out from
under the owner's calendar subscriptions.

The calendar feed token now sits behind TripAccessGuard plus
@RequirePermission('share_manage'), the same shape the rest of the trip-scoped
controllers use, which also replaces a hand-written access check that GET never
called at all. GET is gated with the writes because it returns the credential
itself, not its status. The share link's GET does what its own POST and DELETE
already did, and what the invite link next door already does for exactly this
reason.

Two things had to come with it or the gate would have been decoration. The MCP
tool get_share_link read the same token on membership alone, so it would simply
have become the new way in. And feed_token rides along in every trip payload
because TRIP_SELECT reads t.*: any member could have read the value out of
GET /api/trips/:id, the summary tool or the plugin RPC. It is blanked once in
the select and stripped in the two places that select the row directly.

The three service writes take the acting user and scope their UPDATE to a trip
that user can reach. The guard decides who may manage the credential; this only
makes sure a caller arriving another way cannot write to an id it guessed.

Client side, "Subscribe to calendar" is now behind the same permission on
desktop and mobile. Without that a member would have kept an entry whose enable
button silently does nothing, because IcsSubscribeModal swallows every non-200.
The one-off ICS download stays where it is: that is a file they may already read.

Instances that want members to manage these links can lower share_manage to
trip_member in the admin panel, which the tests cover.
2026-08-11 11:15:32 +02:00
Konstantinos Thermos b4f9e96cc9 fix(feeds): use per-endpoint zones for transport times in the ICS feed (#1874)
A transport also carries a top-level reservation_time, and that branch derives its zone from the linked place, which a flight does not have, so both ends landed as floating times in the subscribed feed (#1453). Consult a timed departure endpoint first instead and keep the IANA zone per side. When the arrival side has no clock of its own, the DTEND still comes from reservation_end_time, so a rental imported with only a geocoded pickup keeps its multi-day return.
2026-08-11 10:17:52 +02:00
bear 1edb531549 fix(auth): send Referer on the Google Maps key validation request (#1862)
The admin panel's test button reported a key with an HTTP referrer restriction as invalid, because the validation call was the one Google request that did not send a Referer. It sends the same header googleFetch already sets for every real request.
2026-08-11 10:17:28 +02:00
Maurice b56dcec118 Type the cron registrar stub so the tests typecheck again
typecheck:tests landed with the contract ratchet; the scheduler migration
merged after it, and its idempotency-cleanup test stubs register() with no
parameters. Reading calls[0][2] back off a no-parameter mock types every
recorded call as [], which is a tuple index error, so dev has been red since.
Give the stub the real register() signature and drop the cast it needed.
2026-08-11 09:58:37 +02:00
Maurice 8e4261ed81 Delete four bridges with no consumer, and gate what runs outside the container
Both were left open by the audit and are closed here.

days, packing, photos and reservations each built a service outside the
container at module load for nobody: only their own delegation tests imported
them, and three domain docblocks were still telling readers to go through them.
photos.bridge's own comment pointed at src/services/, a directory deleted two
PRs ago. The cases that exercised them kept their assertions and call the
service the bridge delegated to, so nothing that was proved stopped being
proved. Twelve bridges remain, each with a live out-of-container caller.

Coverage measured src/** but gated only src/nest/**, so the boot path, the
scheduler, the MCP handler and the migrations sat under no floor at all. That
is the same 9,602 lines where this audit found its worst defect, and a
swallowed EADDRINUSE is exactly the kind of thing an untested boot path hides.
Seven globs now pin what is measured today. They are floors to raise, not
targets that have been met: src/*.ts is at 39% statements and src/db at 38%
branches, and writing those numbers down is the point.

src/demo/** is deliberately left out. It measures 0%, and a floor of zero
asserts nothing; it needs tests before it needs a threshold.
2026-08-11 09:58:37 +02:00
Maurice 4dce8b9a7f Correct the ephemeral-token store's own justification
The docblock said the ws handshake consumes tokens from outside the container.
That was true when it was written and stopped being true one PR later, when the
transport became a gateway that injects this service.

The store still has to be module state, for a reason the comment did not give:
four bridges build EphemeralTokenService by hand, and per-instance state would
give each of them an empty Map, so a download token minted through a bridge
would never validate. An audit lens flagged the stale reasoning; the
requirement survives it.
2026-08-11 09:58:37 +02:00
Maurice 3a7c8e1d26 Make a bind failure fatal again, and undo four contract tightenings
An adversarial audit over the three merged migration PRs. Sixteen findings
survived triple refutation; these are the ones with a defect behind them.

The serious one is mine, from the gateway move. ws registers
`error: this.emit.bind(this, 'error')` on the http server it attaches to, and
the adapter now runs inside buildApp, before listen(). So from that moment the
http server HAS an error listener and Node stops throwing on one. The adapter's
handler was `() => {}`, which meant EADDRINUSE was forwarded there and dropped:
the process survived, the listen callback never ran, so the banner never
printed and the scheduler never started. A container that looks healthy and
serves nothing. Reproduced, then fixed at both ends: index.ts owns the fatal
decision on the http server, and the adapter logs instead of swallowing.

Four DTOs were tightening contracts rather than describing them, which is the
thing the pipe-before-handler ordering makes easy to do by accident:

- OauthClientCreateDto replaced 'Name is required' with a Zod field report, and
  pre-empted the handler's 403 on an instance with the MCP addon off.
- BookingImportConfirmDto demanded per-item fields the service never reads, so
  a caller assembling items itself got a 400 about `source`.
- TagCreateDto/TagUpdateDto typed name and color as strings; the legacy route
  bound whatever arrived, so a numeric name used to create a tag.
- PluginUserSettingsUpdateDto typed config as a record, where the handler
  answers 200 for anything it cannot use.

Two of my own tests were false-green, which the audit demonstrated by mutation
rather than by reading. WSGW-008 asserted on frames that never carry the user
object at all, so it passed with the password_version strip deleted; it reads
the retained identity now. GEO-009 made the same assertion before and after the
write; it distinguishes a cached negative from a miss now, through a hasCached
seam that says what getCached cannot. Both were re-run against the mutation
they exist to catch, and both fail on it.

booking-import geocodes every venue and endpoint in one request loop, up to
thirty sequential calls. Folding maps onto the shared Nominatim cursor put that
loop on the interactive lane, so an import starved every concurrent keystroke
for half a minute. It takes the background lane now, which is what the two-lane
split was built for.

Also: the README's in-container bridge list had outlived one entry and was
missing another, and the journey e2e still mocked a module this branch deleted.
2026-08-11 09:58:37 +02:00
jubnl b82651bf93 Merge remote-tracking branch 'origin/dev' into dev 2026-08-10 22:38:05 +02:00
jubnl 3a48dd7c1c docs: bring the scheduler docs in line 2026-08-10 22:20:11 +02:00
jubnl 08dac57577 refactor(server): migrate plugin jobs off node-cron
The plugin host's job scheduler now uses the cron package (the one
@nestjs/schedule wraps), so node-cron and @types/node-cron leave the
dependency tree entirely. Verified against both real libraries that the
accepted cron grammar is identical — no plugin's declared schedule tightens —
and pinned that with an unmocked compat test.
2026-08-10 22:16:22 +02:00
jubnl f43aed762d refactor(server): retire src/scheduler.ts 2026-08-10 22:04:23 +02:00
jubnl de5d196344 refactor(server): move the cache sweeps, idempotency purge and airtrail poll in-container 2026-08-10 22:02:54 +02:00
jubnl 78b63972d0 refactor(server): move the version check and demo reset into the admin domain 2026-08-10 21:56:13 +02:00
jubnl 1cc46f11b5 refactor(server): move the reminder crons onto the injected notifications 2026-08-10 21:49:27 +02:00
Maurice d981719941 Serve /ws from a Nest gateway with its own adapter
The transport was a hand-rolled setupWebSocket(server) that index.ts kicked
off with a dynamic import after listen(). It is a @WebSocketGateway now, with
DatabaseService and EphemeralTokenService injected instead of imported, and
the heartbeat owned by a lifecycle hook rather than an interval nobody stops.

The wire protocol does not move. Nest's WsAdapter dispatches on
{ event, data } and every deployed client sends { type, tripId }, so
TrekWsAdapter translates rather than asking clients to change. It also builds
the ws server itself instead of delegating: the base's port-0 branch uses
noServer and performs the upgrade from its own registry, which never consults
verifyClient, so delegating would have dropped the origin check and answered
101 to anyone. The per-socket error listener stays attached first, because an
unhandled one kills the process (#1576), and the flood guard counts every
inbound frame before parsing, because malformed frames are exactly the ones
worth limiting.

Four things had to stay where they were, and each is a silent failure if it
does not:

The socket registry stays module state. Seventeen bridges and
notifications.instance build `new RealtimeService()` outside the container;
rooms on a provider instance would leave their broadcasts going to an empty
map, with no error and no log.

The socket id stays a monotonic integer. The client echoes it as X-Socket-Id
and broadcast excludes the originator with Number(excludeSid). A uuid is NaN,
NaN === NaN is false, and every client would receive its own writes back,
which shows up as drag-and-drop that jumps rather than as an error.

buildApp creates the http server and binds the adapter before app.init(), and
callers take it from getHttpServer(). Nest binds gateways during init, so a
later adapter binds nothing; an adapter given the app binds to Nest's own
internal server, which this process never listens on. Both boot green with no
client able to connect.

RealtimeGatewayModule is imported by AppModule alone. RealtimeModule is
@Global, so a gateway there follows into every hand-assembled TestingModule,
and Nest's SocketModule then loads an adapter none of them set: it reaches for
@nestjs/platform-socket.io and calls process.exit(1). Eighteen e2e harnesses
died that way before the split.

src/websocket.ts survives as a re-export of the three fan-out primitives. 115
test files vi.mock that path to assert on broadcasts.

The two transport suites pass unchanged, and 30 new cases pin the pieces
directly: the pv and MFA gates, the numeric socket id, the plugin announcement
firing before the room check, both sides of the origin allowlist, and the
flood guard counting frames no handler wants.
2026-08-10 21:46:47 +02:00
jubnl 0ec611eb98 refactor(server)!: read the reminder gates per tick and drop the app-settings restart
The trip/todo reminder enable gates (notify_trip_reminder, notify_todo_due)
are now read inside each 9 AM tick instead of once at boot, so toggling them
takes effect at the next run without a restart. Saving notification settings
no longer re-arms anything: the auth-side startTripReminders() call and the
shouldRestartScheduler result field are gone (no controller consumed it, so
the PUT /api/auth/app-settings response shape is unchanged). The boot
'enabled/disabled' log lines are informational banners now.
2026-08-10 21:37:45 +02:00
jubnl c63d6430af refactor(server): move the auto-backup cron into the backup domain 2026-08-10 21:33:48 +02:00
jubnl 399bbda0fa feat(server): add the schedule wiring 2026-08-10 21:22:13 +02:00