openapi: 3.1.0
info:
  title: Zügle API
  version: "1.0.0"
  description: |
    Zügle is a Swiss "does it pay off to move?" app. Paste a Flatfox/Homegate listing
    link (or enter costs manually) to compare a candidate apartment against a current
    home: monthly living costs, the Swiss income-tax delta between municipalities
    (federal/cantonal/Gemeinde), commute time and cost, and a quality-of-living score.

    The app has **guest mode**: several routes are reachable with no session cookie at
    all. A guest supplies their own income/relationship/confession/children/current-home
    figures on the request itself (query params or, for ingest, a `profile` body object)
    and gets a computed result back — nothing about a guest request is ever persisted
    server-side. The same routes behave differently for an authenticated household
    member: caller-supplied profile values are ignored and the stored profile
    (`p1`/`p2`) is used instead. Guest traffic is additionally throttled by a set of
    in-memory, per-IP rate-limit buckets described on each public operation; an
    authenticated caller using their own app is never subject to those buckets.
  contact:
    name: Zügle
servers:
  - url: http://localhost:8090
    description: Local dev (npm run dev) or local prod (npm run prod) — both serve on :8090
  - url: https://zuegle.com
    description: >-
      Public production deployment. The VPS holds no open ports: a Cloudflare Tunnel
      (cloudflared) fronts localhost:8090, so TLS terminates at Cloudflare and the app
      runs with TRUST_PROXY_HOPS=1 / FORCE_HTTPS=1. Auto-deployed from the main branch.
  - url: https://zugle-vps.barbel-char.ts.net
    description: >-
      The same VPS over the Tailscale tailnet — admin/debug access that does not depend
      on Cloudflare. Tailscale Funnel is OFF here; a tailnet member reaches this, the
      public internet does not.

tags:
  - name: auth
    description: PIN login, session lifecycle, and data-subject rights (GDPR export/erasure).
  - name: privacy
    description: Export or erase everything Zügle stores about the caller's own profile.
  - name: listings
    description: Ingest, list, re-evaluate and delete apartment-comparison records.
  - name: tax
    description: Swiss income-tax lookups and municipality tax detail, backed by the undocumented ESTV calculator.
  - name: municipalities
    description: Static municipality reference data and favorable-municipality ranking.
  - name: ads
    description: First-party ("house") ad slot — impression selection and click-through.
  - name: admin
    description: Admin-only ad management and event-log inspection. Requires role "admin".
  - name: system
    description: Health/build info and the rotating background photo.

security:
  - cookieAuth: []

paths:
  /api/health:
    get:
      operationId: getHealth
      summary: Health and build info
      description: >-
        Public, unauthenticated, and not subject to any rate-limit bucket. Reports the
        PIN length hints the login pad needs and the build commit/timestamp read once at
        module load (so a changed value proves the process actually restarted onto a new
        build).
      tags: [system]
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HealthInfo' }
              example:
                ok: true
                app: zugle
                signupOpen: true
                minPasswordLength: 10
                commit: "3ca5451"
                builtAt: "2026-08-01T09:12:00.000Z"

  /api/auth/signup:
    post:
      operationId: signup
      summary: Create an account
      description: >-
        Public. Creates an account plus its first (empty) profile in one transaction, and
        signs the caller in. The FIRST account on an installation is created with role
        "admin" — with no admin there would be no way to reach the operator panel, and
        shipping a default admin credential would be worse. Every later signup is a plain
        user. Set ALLOW_SIGNUP=0 to make an instance invite-only, which turns this route
        into a 403. Rate limited to 5 per hour per IP on top of the general guest budget.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, minLength: 10, description: At least 10 characters; no composition rules (NIST SP 800-63B). }
                name: { type: string, description: Optional display name for the first profile. }
              example: { email: "you@example.ch", password: "correct horse battery", name: "Ana" }
      responses:
        '200':
          description: Account created; Set-Cookie carries the session.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Identity' }
        '400':
          description: Invalid address, password too short, or the address is already registered.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "An account with that email already exists" }
        '403':
          description: Registration is closed on this instance (ALLOW_SIGNUP=0).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Too many signups from this IP.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RateLimitError' }

  /api/auth/login:
    post:
      operationId: login
      summary: Log in with email and password
      description: >-
        Public. Verifies the password with scrypt and, on success, sets the HttpOnly
        `zugle_session` cookie (the stored session row holds only the token's SHA-256).
        Returns ONE message for both "no such account" and "wrong password", and an
        unknown address still pays a full scrypt verification, so neither the body nor
        the response time enumerates registered addresses. Guarded by a brute-force
        limiter that is per-IP (10 failures / 15 min) AND per-account (5 / 15 min) —
        either alone leaves the other attack open.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string }
              example: { email: "you@example.ch", password: "correct horse battery" }
      responses:
        '200':
          description: Login succeeded; Set-Cookie carries the session.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: 'zugle_session=<64-hex token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=...'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Identity' }
        '401':
          description: Wrong email or password (deliberately indistinguishable).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "Wrong email or password" }
        '429':
          description: Too many failed attempts for this IP or this account in the last 15 minutes.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds until the oldest failure in the window ages out.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RateLimitError' }
              example: { error: "Too many attempts — try again later", retryAfterSec: 412 }

  /api/auth/forgot:
    post:
      operationId: forgotPassword
      summary: Request a password-reset link by mail
      description: >-
        Public — a locked-out user has no session by definition. **Always returns the same
        200 body**, whether or not the address is registered: anything else would make this
        the account-enumeration oracle that `/api/auth/login` goes out of its way not to be.
        When the address exists, any previously issued link for it is invalidated and a new
        single-use token (valid 1 hour) is mailed out. Mail is optional infrastructure — with
        `SMTP_HOST`/`APP_BASE_URL` unset nothing is sent and the response is unchanged.
        Rate-limited to 5 requests / 15 min.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
              example: { email: "you@example.ch" }
      responses:
        '200':
          description: Request accepted. Says nothing about whether the address exists.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
              example: { ok: true }
        '429':
          description: Too many reset requests from this IP in the last 15 minutes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RateLimitError' }

  /api/auth/reset:
    post:
      operationId: resetPassword
      summary: Redeem a reset token and set a new password
      description: >-
        Public. The token comes from the mailed link's `?reset=` parameter; only its SHA-256
        is stored server-side, so database read access is not account takeover. Single use,
        and unknown / already-used / expired all return the SAME message — which of the
        three it was is information about someone else's inbox. On success every session for
        the account is destroyed (the point of a reset is usually a suspected compromise)
        and a fresh one is issued for the device that redeemed the link.
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string }
              example: { token: "3f1c…", password: "correct horse battery" }
      responses:
        '200':
          description: Password changed; Set-Cookie carries a new session.
          headers:
            Set-Cookie:
              schema: { type: string }
              description: 'zugle_session=<64-hex token>; Path=/; HttpOnly; SameSite=Lax; Max-Age=...'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Identity' }
        '400':
          description: Link no longer valid, or the new password fails the shape rules.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "That reset link is no longer valid — request a new one" }
        '429':
          description: Too many attempts from this IP in the last 15 minutes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RateLimitError' }

  /api/auth/change-password:
    post:
      operationId: changePassword
      summary: Change the current account's password
      description: >-
        Requires a session and the current password. Ends every OTHER session for the
        account (changing a password usually means the old one is believed compromised)
        and issues a fresh cookie for the calling device only.
      tags: [auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentPassword, newPassword]
              properties:
                currentPassword: { type: string }
                newPassword: { type: string, minLength: 10 }
      responses:
        '200':
          description: Password changed; Set-Cookie carries a new session for this device.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean } }
        '400':
          description: Current password wrong, or the new one is too short.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/auth/logout:
    post:
      operationId: logout
      summary: Log out the current session
      description: Destroys the session behind the presented cookie and clears it client-side.
      tags: [auth]
      responses:
        '200':
          description: OK
          headers:
            Set-Cookie:
              schema: { type: string }
              description: Clears the zugle_session cookie (Max-Age=0).
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties: { ok: { type: boolean, example: true } }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/auth/logout-all:
    post:
      operationId: logoutAll
      summary: Log out every device for this profile
      description: Destroys every session belonging to the caller's profile, not just the current one.
      tags: [auth]
      responses:
        '200':
          description: OK
          headers:
            Set-Cookie:
              schema: { type: string }
              description: Clears the zugle_session cookie on this device too (Max-Age=0).
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties: { ok: { type: boolean, example: true } }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/auth/me:
    get:
      operationId: getMe
      summary: Who am I
      description: >-
        Returns the caller's own profileId/role from the session. `role` is absent on
        sessions created before roles existed and defaults to "user" client-side; the
        server always re-checks with a real role gate on admin routes regardless of what
        this reports.
      tags: [auth]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [profileId, role]
                properties:
                  profileId: { type: string, example: p1 }
                  role: { type: string, enum: [user, admin], example: user }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/me/export:
    get:
      operationId: exportMyData
      summary: Download everything stored about my profile (GDPR Art. 15/20)
      description: >-
        Streams a JSON file download — NOT a bare JSON response body semantically, it is
        sent with `Content-Disposition: attachment` so a browser saves it rather than
        rendering it. Contains only the caller's own profile, their listings, and the
        shared app settings; never the other profile, sessions, or server logs.
      tags: [privacy]
      responses:
        '200':
          description: A downloadable JSON file.
          headers:
            Content-Disposition:
              schema: { type: string }
              description: 'attachment; filename="zugle-data-<profileId>-<yyyy-mm-dd>.json"'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportData' }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/me/delete:
    post:
      operationId: deleteMyData
      summary: Erase everything stored about my profile (GDPR Art. 17)
      description: >-
        Hard-deletes the caller's listings, purges every tax-cache entry keyed to their
        exact demographics, resets their profile record to empty defaults, and purges
        their profileId from the event log. Requires an explicit confirmation string so
        this can never be triggered by an accidental click.
      tags: [privacy]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm]
              properties:
                confirm: { type: string, enum: [DELETE], description: Must be the literal string "DELETE". }
      responses:
        '200':
          description: Erasure result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResult' }
              example: { listingsDeleted: 3, taxCacheEntriesPurged: 12, logEventsPurged: 47, profileReset: true }
        '400':
          description: Missing or wrong confirmation string.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: 'Confirmation required — send { confirm: "DELETE" }' }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/ads:
    get:
      operationId: getAd
      summary: Get the currently active ad slot (public shape)
      description: >-
        Public. One weighted-random active ad, already stripped of operator-only fields
        (weight/schedule/enabled/href), or `{ "ad": null }` when nothing is live. Guests
        are most of the traffic, which is why this route is public at all. Rate limit
        (guest only): general public-compute bucket, 60 requests / 5 min per IP.
      tags: [ads]
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ad]
                properties:
                  ad:
                    oneOf:
                      - { $ref: '#/components/schemas/Ad' }
                      - type: 'null'
              example:
                ad: { id: ad_1, headline: "Umzugsfirma XY", body: "Festpreis-Umzüge in der ganzen Schweiz.", image: null, label: Ad }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/ad-click/{id}:
    get:
      operationId: adClick
      summary: Click-through redirect for an ad
      description: >-
        Public via a path-prefix rule (not the exact-match PUBLIC set). Records a click
        server-side and issues a 302 redirect to the ad's target URL — this is a
        redirect response, not a JSON body, deliberately used instead of a client-side
        beacon so no cross-origin request is needed (connect-src is 'self'). No rate
        limit is applied to this route.
      tags: [ads]
      security: []
      parameters:
        - $ref: '#/components/parameters/AdId'
      responses:
        '302':
          description: Redirect to the ad's href.
          headers:
            Location:
              schema: { type: string, format: uri }
              description: The ad's target URL.
        '404':
          description: Unknown ad id, or the ad has no href configured.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "Unknown ad" }

  /api/admin/ads:
    get:
      operationId: adminGetAds
      summary: List every ad (admin) with impression/click stats
      description: Admin only. Returns every ad regardless of active/enabled state, plus in-memory (since-restart) stats.
      tags: [admin]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ads, stats]
                properties:
                  ads:
                    type: array
                    items: { $ref: '#/components/schemas/AdAdmin' }
                  stats:
                    type: object
                    description: Map of adId → { impressions, clicks }, in-memory since the last process restart.
                    additionalProperties:
                      type: object
                      properties:
                        impressions: { type: integer }
                        clicks: { type: integer }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }
    put:
      operationId: adminPutAds
      summary: Replace the whole ad list (admin)
      description: >-
        Admin only. Body is either a bare array of ads or `{ "ads": [...] }`.
        Validated/normalized server-side (`normalizeAds`): unknown fields dropped,
        `href` must be http(s), `image` must be a base64 `data:image/...` URI capped at
        ~200 KB. Accepts up to a 2 MiB request body (data: URI creatives are bulky).
      tags: [admin]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  items: { $ref: '#/components/schemas/AdAdminInput' }
                - type: object
                  properties:
                    ads:
                      type: array
                      items: { $ref: '#/components/schemas/AdAdminInput' }
      responses:
        '200':
          description: The normalized ad list that was saved.
          content:
            application/json:
              schema:
                type: object
                required: [ads]
                properties:
                  ads:
                    type: array
                    items: { $ref: '#/components/schemas/AdAdmin' }
        '400':
          description: Validation error (bad href, oversized/invalid image, etc).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }

  /api/admin/listing-sponsors:
    get:
      operationId: adminGetListingSponsors
      summary: List every sponsored-placement record (admin)
      description: >-
        Admin only. Returns every sponsor record regardless of enabled/schedule state
        (server/listingSponsors.js allSponsors). A listing whose `source` matches an
        active sponsor is reordered to the front of results and always disclosed with
        `sponsored: true` + `sponsorLabel` — see the Sponsor schema.
      tags: [admin]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [sponsors]
                properties:
                  sponsors:
                    type: array
                    items: { $ref: '#/components/schemas/Sponsor' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }
    put:
      operationId: adminPutListingSponsors
      summary: Replace the whole listing-sponsors list (admin)
      description: >-
        Admin only. Body is either a bare array of sponsors or `{ "sponsors": [...] }`.
        Validated/normalized server-side (`normalizeSponsors`): unknown fields dropped,
        `source` is required, `weight` coerced to a positive number (default 1), `label`
        capped at 24 chars (default "Sponsored"). Ranking is deterministic by weight,
        never an auction, and a promoted listing always carries a visible sponsor label.
      tags: [admin]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  items: { $ref: '#/components/schemas/SponsorInput' }
                - type: object
                  properties:
                    sponsors:
                      type: array
                      items: { $ref: '#/components/schemas/SponsorInput' }
      responses:
        '200':
          description: The normalized sponsor list that was saved.
          content:
            application/json:
              schema:
                type: object
                required: [sponsors]
                properties:
                  sponsors:
                    type: array
                    items: { $ref: '#/components/schemas/Sponsor' }
        '400':
          description: Validation error (missing source, etc).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }

  /api/journey:
    post:
      operationId: recordJourneyStep
      summary: Record one funnel step for the current visit
      description: >-
        PUBLIC and rate-limited (60/5min, the shared public-compute budget). Records a step
        name against the caller's `X-Zugle-Journey` id so the funnel can be reconstructed.


        The id is a random UUID generated in the BROWSER, held in sessionStorage and gone
        when the tab closes. It is never generated server-side, never set as a cookie, and
        never stored beside an account id, an email or an IP — it groups requests into a
        visit, it does not identify a person. An unknown step or a malformed id is dropped
        rather than stored, because this body is attacker-controlled.


        Steps that already produce a request of their own (ingest) are logged server-side
        and do not need this route; it exists for actions that leave no other trace, such as
        a page view or opening the paste panel.
      tags: [listings]
      parameters:
        - name: X-Zugle-Journey
          in: header
          required: false
          description: Random per-tab UUID. Omitted or malformed means the step is not recorded.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [step]
              properties:
                step:
                  type: string
                  enum:
                    [
                      app_open,
                      home_view,
                      ingest_submit,
                      ingest_failed,
                      paste_shown,
                      paste_submit,
                      ingest_ok,
                      result_view,
                      result_adjust,
                      saved,
                    ]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, recorded]
                properties:
                  ok: { type: boolean }
                  recorded:
                    type: boolean
                    description: false when no valid journey id was presented — nothing to group by.
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/admin/journeys:
    get:
      operationId: adminGetJourneys
      summary: Funnel, drop-off points and silent-error suspects (admin)
      description: >-
        Admin only. Reconstructs visits from the event log by journey id and reports where
        people stop.


        A journey opens at its first event and closes either at a terminal step
        (`result_view`/`result_adjust`/`saved` — the visitor got what they came for) or after
        30 minutes of silence, which marks it abandoned. A journey still within that window
        is `active` and is counted as neither.


        `suspects` is the point of the route: steps where journeys end in numbers AND where
        nothing was logged as an error. That combination is what a SILENT failure looks like
        from the outside. Journeys that ended after a logged 4xx/429 are excluded — they are
        explained, and mixing them in buries the unexplained ones. Shares
        `server/journey.js` with `npm run journeys`, so the CLI and this route cannot
        disagree.
      tags: [admin]
      parameters:
        - name: hours
          in: query
          required: false
          description: Window to analyse (default 24, max 720).
          schema: { type: integer, default: 24 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [hours, total, concluded, abandoned, active, conversionPct, funnel, dropOffs, suspects]
                properties:
                  hours: { type: integer }
                  total: { type: integer }
                  concluded: { type: integer }
                  abandoned: { type: integer }
                  active: { type: integer }
                  conversionPct:
                    type: integer
                    description: concluded / (concluded + abandoned). Active visits are excluded — counting a visit still in progress as a failure would make this meaningless.
                  funnel:
                    type: array
                    items:
                      type: object
                      properties:
                        step: { type: string }
                        reached: { type: integer }
                        pctOfAll: { type: integer }
                        droppedFromPrevious: { type: integer }
                  dropOffs:
                    type: array
                    items:
                      type: object
                      properties:
                        step: { type: string }
                        journeys: { type: integer }
                        withError: { type: integer }
                        silent: { type: integer }
                  suspects:
                    type: array
                    description: Drop-off steps where most journeys logged no error at all.
                    items:
                      type: object
                      properties:
                        step: { type: string }
                        journeys: { type: integer }
                        silent: { type: integer }
                        silentShare: { type: number }
                  recent:
                    type: array
                    items: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /api/admin/logs:
    get:
      operationId: adminGetLogs
      summary: Read the event log (admin)
      description: >-
        Admin only. Events are newest-first, capped at 2000 total in memory (older
        events are dropped). Query strings are always redacted before being logged,
        so a guest's income never appears here even though it travels on
        favorable-municipalities/municipality-detail requests.
      tags: [admin]
      parameters:
        - name: kind
          in: query
          required: false
          description: Filter to one event kind.
          schema: { type: string, enum: [request, auth, ratelimit, error, ad, journey, listing, mail] }
        - name: limit
          in: query
          required: false
          description: Max events to return (most recent first).
          schema: { type: integer, default: 200 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [events, counts]
                properties:
                  events:
                    type: array
                    items: { $ref: '#/components/schemas/LogEvent' }
                  counts:
                    type: object
                    description: Total event count per kind.
                    additionalProperties: { type: integer }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }
    delete:
      operationId: adminClearLogs
      summary: Clear the event log (admin)
      tags: [admin]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties: { ok: { type: boolean, example: true } }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '403': { $ref: '#/components/responses/AdminOnly' }

  /api/municipalities:
    get:
      operationId: listMunicipalities
      summary: Static municipality reference data
      description: >-
        Public. Serves `data/municipalities.json` verbatim (optionally filtered),
        committed reference data mapping every Swiss municipality's BFS id / ESTV
        TaxLocationID / cantons / zips. Rate limit (guest only): general bucket,
        60 requests / 5 min per IP.
      tags: [municipalities]
      security: []
      parameters:
        - name: zip
          in: query
          required: false
          description: Exact zip match — returns every municipality whose zips[] includes it.
          schema: { type: string }
          example: "8854"
        - name: q
          in: query
          required: false
          description: Case-insensitive substring match on municipality name.
          schema: { type: string }
          example: Sieb
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Municipality' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/background:
    get:
      operationId: getBackground
      summary: Today's rotating background photo
      description: >-
        Public. Deterministic daily pick from the pre-warmed pool in
        `data/backgrounds.json` (see `scripts/refresh-backgrounds.mjs`) — not a live
        Unsplash fetch per page load. `photo` is null when no `UNSPLASH_ACCESS_KEY` is
        configured or the pool is empty. Rate limit (guest only): general bucket,
        60 requests / 5 min per IP.
      tags: [system]
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BackgroundResponse' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/tax:
    get:
      operationId: getTax
      summary: Swiss income tax for one municipality/income/demographic combination
      description: >-
        Requires a session (this route is NOT in the guest-mode public set, even though
        it performs no profile lookup of its own). Backed by the undocumented ESTV
        calculator (server/sources/estv.js) and cached forever per
        (year, bfsId, income, relationship, confession, children). No rate-limit bucket
        beyond authentication itself.
      tags: [tax]
      parameters:
        - name: bfsId
          in: query
          required: true
          schema: { type: integer }
          example: 1346
        - name: income
          in: query
          required: true
          description: Gross annual income, CHF.
          schema: { type: number }
          example: 130000
        - name: year
          in: query
          required: false
          description: Defaults to data/settings.json's taxYear.
          schema: { type: integer }
          example: 2026
        - name: relationship
          in: query
          required: false
          schema: { type: string, enum: [single, married, concubinage, registered_partnership], default: single }
        - name: confession
          in: query
          required: false
          schema: { type: string, enum: [reformed, roman_catholic, christ_catholic, none, other], default: none }
        - name: children
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaxResult' }
        '400':
          description: Missing bfsId or income.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "bfsId is required" }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/favorable-municipalities:
    get:
      operationId: getFavorableMunicipalities
      summary: Rank nearby municipalities by tax delta vs. the current home
      description: >-
        Public (guest mode supported). ALWAYS cache-only and instant: it never fires a
        live ESTV call itself, so `partial: true` plus `cachedCount`/`totalCandidates`
        tells the caller the ranking is incomplete. An incomplete cache triggers a
        decoupled background warm job (`warming` in the response); poll this route again
        to see it fill in. With a session, the stored profile
        (`loadProfile(session.profileId)`) is always used and any of the guest query
        params below are ignored. Without a session, the profile is built from query
        params: `income`, `relationship`, `confession`, `children`, `currentBfsId`
        (required), `currentLabel`, `currentRooms`. Rate limit (guest only): general bucket 60/5min
        + favorable-municipalities bucket 10/hour per IP; a guest-triggered background
        warm additionally shares a global warm-trigger budget of 6 new warm jobs/hour
        across all callers.
      tags: [municipalities]
      security: []
      parameters:
        - name: year
          in: query
          required: false
          description: Defaults to data/settings.json's taxYear.
          schema: { type: integer }
          example: 2026
        - $ref: '#/components/parameters/GuestIncome'
        - $ref: '#/components/parameters/GuestRelationship'
        - $ref: '#/components/parameters/GuestConfession'
        - $ref: '#/components/parameters/GuestChildren'
        - $ref: '#/components/parameters/GuestCurrentBfsId'
        - $ref: '#/components/parameters/GuestCurrentLabel'
        - $ref: '#/components/parameters/GuestCurrentRooms'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FavorableRanking' }
        '400':
          description: currentHome.bfsId missing (no session and no currentBfsId query param, or a stored profile with no home set).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "Set your current home first — currentHome.bfsId is required" }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/listing-recommendations:
    get:
      operationId: getListingRecommendations
      summary: Apartments currently for rent in one municipality
      description: >-
        Public (no profile of any kind — results depend only on WHERE, so the same answer
        serves every caller and the cache is shared by everyone).


        Deliberately NOT cache-only, unlike /api/favorable-municipalities: that route fires
        automatically on every Home page load and must never block, whereas this one fires
        only when a user clicks a specific municipality. A cold key costs a geocode plus two
        upstream flatfox calls (~1-3 s measured); results are then cached for 6 hours in
        logs/listing-cache.json (logs/, not data/ — every guest click writes it, and the
        guest-mode invariant forbids writing to data/).


        Only Flatfox is searchable. Homegate's search is behind a DataDome CAPTCHA and its
        registry entry declares searchListings: null, so it contributes nothing rather than
        silently contributing an empty list.


        Figures here are INDICATIVE and unverified — they come from the portal's JSON feed,
        not from a rendered page, and must never be treated as authoritative or fed into a
        comparison. Ingest a listing URL for that.


        Rate limit (guest only): general bucket 60/5min + listing-recommendations bucket
        20/hour per IP.
      tags: [listings]
      security: []
      parameters:
        - name: bfsId
          in: query
          required: true
          description: BFS municipality number, as returned by /api/favorable-municipalities.
          schema: { type: integer }
          example: 1323
        - name: rooms
          in: query
          required: false
          description: >-
            The caller's own room count. Re-ranks the response so listings within one room of
            it come first and flags each with `fit`; it is a SOFT ranking and never a filter,
            so no listing is dropped and a town offering nothing your size still returns its
            listings. Omitted (or implausible, and it is coerced not trusted) leaves the
            cheapest-first order untouched. Not part of the shared cache key — the cache is
            keyed on bfsId+radius because its contents are the same for everybody, and the
            size preference is applied per request on the way out.
          schema: { type: number }
          example: 3.5
        - name: surface
          in: query
          required: false
          description: >-
            The caller's own floor area in m², used only as a tiebreak between listings of
            equal room similarity. Rooms dominate on purpose: it is what people search on and
            what portals almost always state, while surface is missing often enough that
            letting it drive would rank listings by how completely the form was filled in.
          schema: { type: number }
          example: 95
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingRecommendations' }
        '400':
          description: bfsId missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No municipality with that bfsId
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/municipality-detail:
    get:
      operationId: getMunicipalityDetail
      summary: Full tax breakdown + multi-year trend for one municipality
      description: >-
        Public (guest mode supported). Same session-vs-guest profile split as
        favorable-municipalities. Fetches the current tax year plus up to 7 years of
        history (concurrency-limited against ESTV), so unlike favorable-municipalities
        this CAN make live ESTV calls and is not instant. Rate limit (guest only):
        general bucket 60/5min + municipality-detail bucket 30/hour per IP.
      tags: [tax]
      security: []
      parameters:
        - name: bfsId
          in: query
          required: true
          schema: { type: integer }
          example: 1321
        - name: year
          in: query
          required: false
          description: Clamped to at most data/settings.json's taxYear (never a future/unpublished year).
          schema: { type: integer }
          example: 2026
        - name: years
          in: query
          required: false
          description: Size of the trend window, clamped to 1-7.
          schema: { type: integer, default: 5 }
        - $ref: '#/components/parameters/GuestIncome'
        - $ref: '#/components/parameters/GuestRelationship'
        - $ref: '#/components/parameters/GuestConfession'
        - $ref: '#/components/parameters/GuestChildren'
        - $ref: '#/components/parameters/GuestCurrentBfsId'
        - $ref: '#/components/parameters/GuestCurrentLabel'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MunicipalityDetail' }
        '400':
          description: Missing bfsId, or currentHome.bfsId missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Unknown bfsId.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "Unknown bfsId 999999" }
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: ESTV upstream failure and no cached data available for the requested year.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/commute:
    get:
      operationId: getCommute
      summary: Public-transit commute time between two places
      description: >-
        Requires a session (NOT in the guest-mode public set). Median duration across
        the next few transport.opendata.ch connections between `from` and `to`.
      tags: [municipalities]
      parameters:
        - name: from
          in: query
          required: true
          description: A "zip city" string or a "lat,lng" string.
          schema: { type: string }
          example: "8854 Siebnen"
        - name: to
          in: query
          required: false
          schema: { type: string, default: "Zürich HB" }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommuteInfo' }
        '400':
          description: Missing "from".
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "from is required" }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/listings/ingest:
    post:
      operationId: ingestListing
      summary: Fetch a listing (or accept manual data), compare it, and evaluate it
      description: >-
        Public (guest mode supported) — the app's primary entry point. Orchestrates:
        parse a flatfox.ch/homegate.ch URL (or accept a manual entry) → resolve its
        municipality → fetch Swiss income tax for both candidate and current home →
        fetch commute time/cost → compute the cost/tax comparison → compute the
        quality-of-living score → assemble a ListingRecord. With a session, the result
        is persisted (prepended to data/listings.json) and `body.profile`/`body.profileId`
        are ignored in favor of the stored profile. WITHOUT a session, nothing is ever
        written to disk and the caller MUST supply a full profile object in
        `body.profile` (contract: incomeAnnual, relationship, confession, children,
        currentHome{bfsId,...}, weights, commuteTarget, commuteCostMode). Rate limit
        (guest only): general bucket 60/5min + ingest bucket 10/hour per IP, charged
        ONCE up front regardless of which `input` kind the body turns out to be — a
        search-shaped input never costs two budgets.


        `body.input` is the hero-input contract: one free-form string, sniffed
        server-side (server/inputRouter.js, server/intent.js) into a listing URL, a
        pasted page (plain text / page source / the bookmarklet's JSON), or free text
        such as "3.5 rooms near Zug under 2500, quiet". A successful listing ingest via
        `input` returns exactly the same 200 ListingRecord as the legacy shapes below —
        no `kind` field is added to that case. A walled portal (Homegate) no longer
        answers with a 422: it now returns 200 with `kind: 'suggestions'`, because a
        search result found in place of a blocked link is an answer, not a failure, and
        the old 422 is what made the paste flow read as an error when it was really an
        offer of alternatives. The paste affordance (`bookmarklet`) is still there, just
        demoted to a secondary field on that response instead of being the only thing
        returned. The three legacy body shapes below (`url`, `pastedHtml`, `manual`)
        still work verbatim and keep their own documented 422 — `resolveCandidate`'s
        errors are unchanged for those.
      tags: [listings]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IngestRequest' }
            examples:
              byInputUrl:
                summary: Hero input — a listing link (authenticated — no profile needed)
                value:
                  input: "https://flatfox.ch/en/flat/some-listing/85823796/"
              byInputFreeText:
                summary: Hero input — free text describing what the caller wants
                value:
                  input: "3.5 rooms near Zug under 2500, quiet"
              byUrl:
                summary: Legacy — ingest by pasting a flatfox.ch URL (authenticated — no profile needed)
                value:
                  url: "https://flatfox.ch/en/flat/some-listing/85823796/"
              manualGuest:
                summary: Manual entry as a guest (profile required)
                value:
                  manual:
                    zip: "8834"
                    city: Schindellegi
                    rentNet: 2100
                    rentCharges: 150
                  profile:
                    incomeAnnual: 130000
                    relationship: single
                    confession: none
                    children: 0
                    commuteTarget: "Zürich HB"
                    weights: { commute: 0.35, center: 0.15, lake: 0.1, view: 0.2, green: 0.2 }
                    currentHome:
                      bfsId: 1346
                      label: "Siebnen SZ"
                      zip: "8854"
                      city: Siebnen
                      rentNet: 1360
                      rentCharges: 0
                      parking: 100
                      electricity: 200
                      extraBills: 0
                      commuteCostMonthly: 320
      responses:
        '200':
          description: >-
            A discriminated union on `kind`. A plain ListingRecord (no `kind` field) is
            a successful ingest — via `input` OR via any legacy body shape, unchanged.
            When `body.input` was used and the input sniffed to a wall/paste/free-text
            case, the record is instead one of IngestSuggestions ('suggestions'),
            IngestSearch ('search'), or IngestManual ('manual') — see those schemas.
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: '#/components/schemas/ListingRecord' }
                  - { $ref: '#/components/schemas/IngestSuggestions' }
                  - { $ref: '#/components/schemas/IngestSearch' }
                  - { $ref: '#/components/schemas/IngestManual' }
        '400':
          description: >-
            Missing/invalid profile (no session and no body.profile, or currentHome.bfsId
            unset), unrecognized listing URL, neither input/url/pastedHtml/manual
            provided, empty `input`, or the listing's zip is not in
            data/municipalities.json.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422':
          description: >-
            The legacy `url`/`pastedHtml` bodies only. Homegate could not be parsed
            server-side (expected — Homegate 403s server-side fetches behind Cloudflare
            most of the time). The client is expected to fall back to its manual-entry
            form. A `body.input` that sniffs to a URL takes this same wall and turns it
            into a 200 `kind: 'suggestions'` instead — see the operation description.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/NeedsManualError' }
              example:
                error: "Could not parse Homegate listing"
                needsManual: true
                partial: {}
                cause: "homegate 403"
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/listings:
    get:
      operationId: listListings
      summary: List saved evaluations
      description: Requires a session. Returns data/listings.json, newest first, optionally filtered by profileId.
      tags: [listings]
      parameters:
        - name: profileId
          in: query
          required: false
          schema: { type: string, example: p1 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ListingRecord' }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/listings/{id}:
    get:
      operationId: getListing
      summary: Get one saved evaluation
      tags: [listings]
      parameters:
        - $ref: '#/components/parameters/ListingId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingRecord' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '404':
          description: No listing with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      operationId: deleteListing
      summary: Delete a saved evaluation
      tags: [listings]
      parameters:
        - $ref: '#/components/parameters/ListingId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties: { ok: { type: boolean, example: true } }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '404':
          description: No listing with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      operationId: updateListing
      summary: Re-evaluate a saved listing after edits
      description: >-
        Requires a session. Merges `body.overrides` and `body.quality` into the stored
        record, re-resolves the current home's commute cost (a fresh
        `resolveLocation()` call, unlike ingest-time caching), and recomputes
        `comparison` and `quality`. Cached tax totals are reused as-is — this never
        re-fetches ESTV.
      tags: [listings]
      parameters:
        - $ref: '#/components/parameters/ListingId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                overrides:
                  type: object
                  description: Partial patch merged onto the record's existing overrides.
                  properties:
                    rentNet: { type: [number, 'null'], description: "Overrides the candidate's scraped/manual rentNet. null clears the override and falls back to the record's own figure (or its rentGross, if that is all it has) — never 0, which would claim the rent is free." }
                    rentCharges: { type: [number, 'null'], description: "Overrides the candidate's scraped/manual rentCharges, independently of rentNet. Same null-clears convention." }
                    parking: { type: number }
                    electricity: { type: number }
                    extraBills: { type: number }
                    commuteCostMonthly: { type: number }
                    taxAdjustmentAnnual: { type: number }
                    features:
                      type: object
                      description: >-
                        Merged one level DEEPER than the rest of overrides, so patching a
                        single field does not clear the others. Each value overrides the
                        scraped listing; null clears the override.
                      properties:
                        rooms: { type: [number, 'null'] }
                        surface: { type: [number, 'null'] }
                        floor: { type: [integer, 'null'] }
                        renovationYear: { type: [integer, 'null'] }
                        balcony: { type: [boolean, 'null'] }
                quality:
                  type: object
                  description: Partial patch merged onto the record's existing qualityInputs.
                  properties:
                    view: { type: number, minimum: 0, maximum: 10 }
                    green: { type: number, minimum: 0, maximum: 10 }
              example:
                overrides: { commuteCostMonthly: 280, features: { surface: 95, balcony: true } }
                quality: { view: 8 }
      responses:
        '200':
          description: The updated record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingRecord' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '404':
          description: No listing with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/listing-variants:
    post:
      operationId: createListingVariant
      summary: Fork a saved listing into a named what-if scenario
      description: >-
        Requires a session. Applies the same `overrides`/`quality` edits PATCH accepts, runs
        the same recompute (server/listingRecompute.js), and INSERTS the result as a new
        listing carrying `variantOf` and `label` — leaving the original untouched, so
        "what if I negotiated 200 off?" can be read beside the real figures instead of
        replacing them.

        Like PATCH, it never re-fetches the portal or ESTV: a scenario is a what-if over
        figures already on the record. `variantOf` always points at a ROOT listing — forking
        a variant re-parents to that variant's own origin, so grouping stays one level deep.

        A listing belonging to another account gives the same 404 as a non-existent id.
      tags: [listings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sourceId, label]
              properties:
                sourceId:
                  type: string
                  description: >-
                    The listing to fork. In the BODY rather than the path because the router
                    matches only a single trailing /:id — `/api/listings/{id}/variant` would
                    never match a request.
                label:
                  type: string
                  maxLength: 80
                  description: The scenario's name. Required — an unnamed scenario is not worth keeping beside the original.
                overrides:
                  type: object
                  description: Same shape as PATCH's `overrides`, merged onto the source record's.
                quality:
                  type: object
                  description: Same shape as PATCH's `quality`.
              example:
                label: rent negotiated −200
                overrides: { features: { surface: 95 } }
      responses:
        '200':
          description: The newly created variant record, with its own id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListingRecord' }
        '400':
          description: Missing or empty `sourceId` or `label`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '404':
          description: No listing with that id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/profiles:
    get:
      operationId: getProfiles
      summary: List this account's profiles
      description: >-
        Requires a session. Returns ONLY the calling account's profiles — an account may
        hold several ("me" and "my partner"). Tenancy is at the account level; a profile
        id is a filter within an account and is never an identity.
      tags: [listings]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Profile' }
        '401': { $ref: '#/components/responses/SessionExpired' }
    put:
      operationId: putProfiles
      summary: Update this account's profiles
      description: >-
        Requires a session. Each element is MERGED onto the stored profile of the same id,
        so a partial body never blanks stored fields. The body can never change the SET of
        profiles: an id that is not already this account's is rejected with the same 400
        as one that does not exist, so a body cannot probe other accounts' profile ids,
        invent a profile, or delete one by omission.
        When a profile's currentHome.zip changed since the stored version (or has no
        resolved bfsId yet), the server re-resolves currentHome.bfsId from
        data/municipalities.json — requiring an exact Gemeinde-name match in `city` when
        the zip spans multiple municipalities, rather than silently guessing.
      tags: [listings]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: { $ref: '#/components/schemas/Profile' }
      responses:
        '200':
          description: The saved profiles (with currentHome.bfsId resolved).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Profile' }
        '400':
          description: Body isn't an array, an unknown zip, or an ambiguous zip needing an exact city name.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { error: "ZIP 8854 matches multiple municipalities (Galgenen, Schübelbach, Wangen (SZ)) for Danilo — set City to the exact Gemeinde name to disambiguate." }
        '401': { $ref: '#/components/responses/SessionExpired' }
    post:
      operationId: addProfile
      summary: Add a profile to this account
      description: Requires a session. Capped at 4 profiles per account.
      tags: [listings]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties: { name: { type: string } }
      responses:
        '200':
          description: The created profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Profile' }
        '400':
          description: The account already holds the maximum number of profiles.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }

  /api/profiles/{id}:
    delete:
      operationId: deleteProfile
      summary: Delete one of this account's profiles
      description: >-
        Requires a session. Scoped to the caller — a profile belonging to another account
        returns the same 404 as one that does not exist. Refuses to delete the last
        profile: an account with none is a state no compute route has a branch for.
      tags: [listings]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean } }
        '400':
          description: This is the account's last profile.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/SessionExpired' }
        '404':
          description: No such profile on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/settings:
    get:
      operationId: getSettings
      summary: Get the caller's settings
      description: >-
        Requires a session. `taxYear` is installation-wide (which year's Swiss tax law is
        in force — operator-set); `searchCosts` are the CALLER'S own one-off receipts.
        These used to share one global file, which meant one household's fees were summed
        into every other user's comparison.
      tags: [listings]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Settings' }
        '401': { $ref: '#/components/responses/SessionExpired' }
    put:
      operationId: putSettings
      summary: Update settings
      description: >-
        Requires a session. `searchCosts` is normalized and saved against the caller's own
        account. `taxYear` is installation-wide and therefore admin-only — a non-admin
        body containing it gets a 403 rather than a silently ignored field.
      tags: [listings]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Settings' }
      responses:
        '200':
          description: The saved settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Settings' }
        '401': { $ref: '#/components/responses/SessionExpired' }

components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: zugle_session
      description: >-
        64-hex-character session token set as an HttpOnly, SameSite=Strict cookie by
        POST /api/auth/login. Routes NOT in the guest-mode public set return 401 with
        code SESSION_EXPIRED when it is missing or invalid.

  parameters:
    ListingId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      example: "b6c1e2a0-1234-4a5b-9c0d-abcdef123456"
    AdId:
      name: id
      in: path
      required: true
      schema: { type: string }
      example: ad_1
    GuestIncome:
      name: income
      in: query
      required: false
      description: Guest mode only — gross annual income, CHF. Ignored entirely when a session is present.
      schema: { type: number }
      example: 130000
    GuestRelationship:
      name: relationship
      in: query
      required: false
      description: Guest mode only. Ignored entirely when a session is present.
      schema: { type: string, enum: [single, married, concubinage, registered_partnership], default: single }
    GuestConfession:
      name: confession
      in: query
      required: false
      description: Guest mode only. Ignored entirely when a session is present.
      schema: { type: string, enum: [reformed, roman_catholic, christ_catholic, none, other], default: none }
    GuestChildren:
      name: children
      in: query
      required: false
      description: Guest mode only. Ignored entirely when a session is present.
      schema: { type: integer, default: 0 }
    GuestCurrentBfsId:
      name: currentBfsId
      in: query
      required: false
      description: >-
        Guest mode only — the BFS id of the guest's own current-home municipality.
        Required for a guest caller (400 without it); ignored entirely when a session
        is present, where the stored profile's currentHome.bfsId is used instead.
      schema: { type: integer }
      example: 1346
    GuestCurrentLabel:
      name: currentLabel
      in: query
      required: false
      description: Guest mode only — display label for the guest's current home. Ignored entirely when a session is present.
      schema: { type: string }
      example: "Siebnen SZ"
    GuestCurrentRooms:
      name: currentRooms
      in: query
      required: false
      description: >-
        Guest mode only — the guest's current-home room count, used to pick the room-size
        bucket for each item's expected-rent figure. Optional: without it the rent context
        is simply absent from the response. Ignored entirely when a session is present.
      schema: { type: number }
      example: 3.5

  responses:
    SessionExpired:
      description: >-
        No valid session cookie. This is the ONE error shape in the whole API that
        bypasses the standard ApiError → handleRouteError path (it's written directly by
        auth.js's authGate middleware before the route dispatcher runs), which is why it
        alone carries a `code` field.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SessionExpiredError' }
          example: { error: "Unauthorized — please log in", code: "SESSION_EXPIRED" }
    AdminOnly:
      description: Valid session, but its role is not "admin".
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: "Admin only" }
    RateLimited:
      description: Guest-mode rate limit exceeded for this route's bucket (or the shared general bucket).
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the oldest request in the window ages out.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RateLimitError' }
          example: { error: "Too many requests", retryAfterSec: 137 }

  schemas:
    BfsRent:
      type: object
      properties:
        value: { type: number, example: 1719, description: Average rent in CHF per month. }
        basis:
          type: string
          enum: [city, canton]
          description: >-
            WHICH GEOGRAPHY the figure describes. The UI must surface this — rendering a
            cantonal average under a municipality's name without saying so presents one
            geography's number as another's.
        label: { type: string, example: SZ, description: The canton abbreviation, or the city name. }
        year:
          oneOf: [{ type: integer }, { type: string }]
          description: A year for canton figures; a cumulated window like "2022-2024" for cities.
          example: 2024
        marginChf:
          type: number
          nullable: true
          example: 32
          description: >-
            The ± confidence interval BFS publishes. Deliberately not a sample count — BFS
            does not publish one, and inventing it would be fabrication.
        source:
          type: object
          description: Attribution required by the terms_by licence.
          properties:
            name: { type: string, example: BFS Mietpreiserhebung (Strukturerhebung) }
            url: { type: string, format: uri }
            retrievedAt: { type: string, format: date-time }
    LiveRentStats:
      type: object
      properties:
        median: { type: number, example: 1760, description: Median asking rent, CHF/month. }
        count: { type: integer, example: 21, description: Usable listings the median was computed over. }
        medianPerM2:
          type: number
          nullable: true
          example: 36.1
          description: >-
            Median of the per-listing CHF/m² ratios — not median rent divided by median
            surface, which is a different and wrong statistic.
        sizedCount:
          type: integer
          description: >-
            Listings whose room count is known — the denominator for "what share of this
            market is my size". Deliberately not `count`, which counts PRICED listings.
          example: 24
        byRooms:
          type: object
          description: >-
            The room-size distribution of what is on the market here, keyed by room count
            snapped to the nearest half ("1", "1.5", "2"…). Two consumers with different
            strictness, hence two fields per bucket: `count` always (server/recommend.js's
            `fit` criterion needs the counts, and two 4.5s on offer is a real fact), `median`
            only above 5 listings in that bucket (a median over two is noise). Never guessed
            from a neighbouring bucket.
          additionalProperties:
            type: object
            properties:
              count: { type: integer, example: 6 }
              median: { type: number, nullable: true, example: 2450 }
    ListingRecommendations:
      type: object
      description: >-
        Listings found in one municipality. `cached` says whether this came from the 6-hour
        cache. Every figure is indicative — see the route description.
      properties:
        bfsId: { type: integer, example: 1323 }
        name: { type: string, example: Wollerau }
        canton: { type: string, example: SZ }
        cached: { type: boolean }
        fetchedAt:
          type: integer
          description: Epoch ms when the portals were last queried for this municipality.
        items:
          type: array
          items: { $ref: '#/components/schemas/ListingSummary' }
        matchedCount:
          type: integer
          nullable: true
          description: >-
            How many of `items` are within one room of the caller's `rooms`. Null when no
            size was supplied, in which case `items` keeps the plain cheapest-first order and
            carries no `fit`/`similarity`.
          example: 7
        rent:
          description: >-
            Official BFS average rent. NOTE the geography: BFS does not publish rent below
            canton level (the source is a ~200k-household sample, so per-commune cells fall
            under the confidentiality threshold), so for almost every municipality `basis`
            is "canton" and the figure describes the canton, not the town. Only the 10
            cities BFS tabulates individually return basis "city". Null when no figure
            applies — the national average is never substituted. Licensed terms_by:
            consumers must display the attribution carried in `source`.
          nullable: true
          allOf: [{ $ref: '#/components/schemas/BfsRent' }]
        rentStats:
          description: >-
            Live median ASKING rent of listings currently advertised in this municipality —
            a different question from `rent`, and never merged with it. Computed over the
            full residential result set before the cheapest-first sort and the item limit,
            which is what makes it a median rather than a floor. Null when fewer than 5
            usable listings, and null when the area hit the provider's result cap (a capped
            subset's median is not the municipality's median).
          nullable: true
          allOf: [{ $ref: '#/components/schemas/LiveRentStats' }]
        errors:
          type: array
          description: >-
            Per-provider failures. A portal that fails degrades the list instead of blanking
            it, and the failure is reported rather than dropped — an empty list with no
            errors genuinely means "nothing for rent here".
          items:
            type: object
            properties:
              source: { type: string, example: flatfox }
              error: { type: string }
    ListingSummary:
      type: object
      description: >-
        The public projection of one listing (server/listingSponsors.js toPublicListing).
        Sponsor internals - weight, schedule, enabled - are never included.
      properties:
        source: { type: string, example: flatfox }
        sourceId: { type: string, example: '86221536' }
        url: { type: string, format: uri }
        title: { type: string, nullable: true }
        zip: { type: string, nullable: true, example: '8832' }
        city: { type: string, nullable: true, example: Wollerau }
        rooms: { type: number, nullable: true, example: 3.5 }
        surface: { type: number, nullable: true, example: 102 }
        rentGross: { type: number, nullable: true, example: 3400 }
        image: { type: string, nullable: true, format: uri }
        sponsored:
          type: boolean
          description: >-
            Present and true only on a paid placement. Always accompanied by sponsorLabel,
            which the UI must display - there is no way to promote a listing silently.
        sponsorLabel: { type: string, nullable: true, example: Sponsored }
        fit:
          type: boolean
          description: >-
            Present only when the request supplied a size. True when this listing is within
            one room of the caller's, and true for a listing that states no room count at all
            — an unstated size is not evidence of a mismatch, and hiding it behind the "show
            all" toggle would be a hard filter by the back door.
        similarity:
          type: number
          nullable: true
          description: >-
            0-1, how close this listing is to the caller's home (rooms dominant, m² as
            tiebreak). Present only when the request supplied a size. The sort key behind the
            order of `items`.
          example: 0.87
    Error:
      type: object
      description: >-
        The base shape of every error response in this API: `{ error: <message>,
        ...extra }`, produced by throwing `ApiError(status, message, extra)` in
        server/app.js (caught centrally by handleRouteError) or, for unmatched routes,
        written directly by the dispatcher.
      required: [error]
      properties:
        error: { type: string }
      additionalProperties: true

    RateLimitError:
      description: A 429 from any of the guest-mode rate-limit buckets, or the login brute-force limiter.
      allOf:
        - { $ref: '#/components/schemas/Error' }
        - type: object
          required: [retryAfterSec]
          properties:
            retryAfterSec: { type: integer, description: Also sent as the Retry-After response header. }

    SessionExpiredError:
      allOf:
        - { $ref: '#/components/schemas/Error' }
        - type: object
          required: [code]
          properties:
            code: { type: string, enum: [SESSION_EXPIRED] }

    NeedsManualError:
      description: The 422 Homegate ingest failure — client should fall back to its manual-entry form.
      allOf:
        - { $ref: '#/components/schemas/Error' }
        - type: object
          required: [needsManual]
          properties:
            needsManual: { type: boolean, enum: [true] }
            partial:
              type: object
              description: Best-effort partial listing fields recovered before the failure (currently always empty).
            cause: { type: string, description: The underlying error message. }

    Identity:
      type: object
      required: [userId, role]
      description: Who the session belongs to. Returned by signup and login.
      properties:
        userId: { type: string, example: u_xRTAzIJhYMHC }
        email: { type: string, format: email }
        role:
          type: string
          enum: [user, admin]
          description: >-
            A UI hint only — every admin route re-checks server-side with requireAdmin,
            so claiming "admin" client-side gets a 403 and nothing else.

    HealthInfo:
      type: object
      required: [ok, app, signupOpen, minPasswordLength, commit, builtAt]
      properties:
        ok: { type: boolean }
        app: { type: string, enum: [zugle] }
        signupOpen: { type: boolean, description: False when ALLOW_SIGNUP=0 makes the instance invite-only. }
        minPasswordLength: { type: integer, description: So the sign-up form can state the rule before submitting. }
        commit:
          type: [string, 'null']
          description: Short build SHA from dist/build-info.json, read once at process start. Null in dev (no dist/).
        builtAt:
          type: [string, 'null']
          format: date-time

    Municipality:
      type: object
      required: [bfsId, taxLocationId, name, canton, zips]
      properties:
        bfsId: { type: integer, description: Official Swiss BFS commune number. }
        taxLocationId: { type: integer, description: ESTV's internal TaxLocationID, used to call the tax calculator. }
        name: { type: string }
        canton: { type: string, minLength: 2, maxLength: 2, example: SZ }
        zips:
          type: array
          items: { type: string }
      example: { bfsId: 1346, taxLocationId: 891460000, name: "Schübelbach", canton: SZ, zips: ["8854"] }

    Weights:
      type: object
      description: Quality-score weighting; unset keys fall back to server defaults and weights are renormalized over available components.
      properties:
        commute: { type: number }
        center: { type: number }
        lake: { type: number }
        view: { type: number }
        green: { type: number }
      example: { commute: 0.35, center: 0.15, lake: 0.1, view: 0.2, green: 0.2 }

    CurrentHome:
      type: object
      properties:
        label: { type: string, example: "Siebnen SZ" }
        bfsId: { type: [integer, 'null'], example: 1346 }
        zip: { type: string, example: "8854" }
        city: { type: string, example: Siebnen }
        rooms: { type: [number, 'null'], example: 3.5 }
        surface:
          type: [number, 'null']
          description: >-
            Living space in m². Together with `rooms` this drives the value-for-money
            comparison (server/value.js). Null means unknown, never zero — an unknown
            component redistributes its weight rather than scoring 0.
          example: 70
        floor:
          type: [number, 'null']
          description: >-
            0 = ground floor. Reported and diffed, but deliberately NOT scored: a higher
            floor has no universally better direction.
          example: 2
        renovationYear: { type: [number, 'null'], example: 2015 }
        balcony:
          type: [boolean, 'null']
          description: Tri-state — null means "not stated", which is distinct from false.
          example: true
        rentNet: { type: number, example: 1360 }
        rentCharges: { type: number, example: 0 }
        parking: { type: number, example: 100 }
        electricity: { type: number, example: 200 }
        extraBills: { type: number, example: 0 }
        commuteCostMonthly:
          type: [number, 'null']
          description: >-
            Falsy (0, null or undefined) means UNSET — only a truthy value is an
            explicit override that short-circuits the fare-zone auto-calc.
          example: 320
        commuteMinutes: { type: [number, 'null'], example: 40 }
        quality:
          type: object
          properties:
            view: { type: number, minimum: 0, maximum: 10, example: 6 }
            green: { type: number, minimum: 0, maximum: 10, example: 7 }

    Profile:
      type: object
      required: [id, name, incomeAnnual, relationship, confession, children, currentHome]
      properties:
        id: { type: string, example: p1 }
        name: { type: string, example: Danilo }
        incomeAnnual: { type: number, example: 130000 }
        relationship: { type: string, enum: [single, married, concubinage, registered_partnership] }
        confession: { type: string, enum: [reformed, roman_catholic, christ_catholic, none, other] }
        children: { type: integer, example: 0 }
        currentHome: { $ref: '#/components/schemas/CurrentHome' }
        commuteTarget: { type: string, example: "Zürich HB" }
        commuteCostMode: { type: string, enum: [sbb, car], default: sbb }
        weights: { $ref: '#/components/schemas/Weights' }

    SearchCost:
      type: object
      properties:
        label: { type: string, example: Betreibungsregisterauszug }
        amount: { type: number, example: 17 }
        date: { type: string, format: date, example: "2026-07-01" }

    Settings:
      type: object
      required: [taxYear]
      properties:
        taxYear:
          type: integer
          example: 2026
          description: Installation-wide (which year's Swiss tax law is in force). Admin-only to change.
        searchCosts:
          type: array
          items: { $ref: '#/components/schemas/SearchCost' }

    TaxRates:
      type: object
      description: Steuerfuss (multiplier), as % of the "simple"/einfache tax — the number a Gemeinde actually votes on. Absent on legacy v1 cache entries.
      properties:
        canton: { type: [number, 'null'] }
        city: { type: [number, 'null'] }
        protestant: { type: [number, 'null'] }
        roman: { type: [number, 'null'] }

    TaxIncomeBreakdown:
      type: object
      description: The deductions ESTV applied to get from gross salary to net/taxable income. Absent on legacy v1 cache entries.
      properties:
        gross: { type: number }
        net: { type: number }
        ahv: { type: number }
        alv: { type: number }
        nbu: { type: number }
        bvg: { type: number }

    TaxResult:
      type: object
      description: >-
        Result of server/sources/estv.js getTax(), cached forever per
        (year, bfsId, income, relationship, confession, children). v1 cache entries
        (pre-dating the detailed breakdown) carry only the fields marked required below;
        v2 entries (current) additionally carry rates/income/simpleTax*/taxableIncome*.
      required: [total, federal, cantonal, municipal, church, personal, year, cached]
      properties:
        total: { type: number, description: Total tax, CHF/year.  }
        federal: { type: number }
        cantonal: { type: number }
        municipal: { type: number }
        church: { type: number }
        personal: { type: number }
        simpleTaxFed: { type: number, description: "\"Einfache Steuer\" before the federal multiplier." }
        simpleTaxCanton: { type: number }
        simpleTaxCity: { type: number }
        taxableIncomeFed: { type: number }
        taxableIncomeCanton: { type: number }
        marginalTaxRate: { type: [number, 'null'] }
        rates: { $ref: '#/components/schemas/TaxRates' }
        income: { $ref: '#/components/schemas/TaxIncomeBreakdown' }
        year: { type: integer }
        v: { type: integer, description: Cache schema version (1 or 2). Absent on the oldest entries, treated as 1. }
        cached: { type: boolean, description: True when served from the tax cache rather than freshly computed. }
      example:
        total: 14550
        federal: 3480
        cantonal: 3966
        municipal: 7104
        church: 0
        personal: 0
        simpleTaxFed: 348
        simpleTaxCanton: 1322
        simpleTaxCity: 1184
        taxableIncomeFed: 105200
        taxableIncomeCanton: 98700
        marginalTaxRate: 0.24
        rates: { canton: 300, city: 600, protestant: 12, roman: 12 }
        income: { gross: 130000, net: 118400, ahv: 8515, alv: 1430, nbu: 655, bvg: 8600 }
        year: 2026
        v: 2
        cached: true

    FavorableItem:
      type: object
      required: [bfsId, name, canton, total, delta]
      properties:
        bfsId: { type: integer }
        name: { type: string }
        canton: { type: string }
        total: { type: number, description: That municipality's total annual tax, CHF. }
        delta: { type: number, description: total − current home's total. Negative = cheaper.  }
        rent:
          type: ['object', 'null']
          description: >-
            Expected monthly rent for the caller's room count. `basis` is REQUIRED reading,
            not decoration: BFS publishes no rent below canton level (see
            server/rentStats.js), so `canton` is a cantonal average, `city` is one of the 10
            municipalities BFS names individually, and `live` is the median ASKING price of
            listings currently on the market — municipality-level, but a different statistic
            from the BFS figures, and only present when a previous listing-search click
            already cached it.
          properties:
            value: { type: number }
            basis: { type: string, enum: [live, city, canton] }
            label: { type: string, description: The geography the figure actually describes. }
            count: { type: integer, description: Sample size — `live` basis only. }
            year: { type: [string, integer, 'null'] }
            roomsLabel: { type: [string, 'null'] }
            interpolated:
              type: boolean
              description: True when a half-room count was interpolated between two BFS whole-room buckets.
        rentDelta:
          type: [number, 'null']
          description: >-
            Monthly rent difference vs the current home, or null. Only ever computed between
            two figures of the SAME basis — comparing a live asking price against a BFS
            average would manufacture a difference out of the change in statistic.
        totalDelta:
          type: [number, 'null']
          description: >-
            delta + rentDelta × 12. Kept separate from `delta` because tax is exact and
            per-municipality while the rent term usually is not; the UI ranks on this only
            when the user opts into the combined view.
        lat: { type: [number, 'null'], description: Municipality centroid, absent for ones that never geocoded. }
        lng: { type: [number, 'null'] }
        match:
          type: [number, 'null']
          description: >-
            0-100 blended personal fit, from server/recommend.js — the "For you" tab's
            ranking. Deterministic and pure: the same figures always produce the same score,
            and ties break on bfsId so the order never depends on the order rows were
            computed in. Null when no criterion could speak for this municipality at all;
            such rows sink to the bottom rather than being dropped.
        confidence:
          type: [number, 'null']
          description: >-
            0-1. The share of the intended weighting that was actually backed by a figure
            about THIS municipality. A row ranked partly on a cantonal rent average scores
            lower here than one ranked on exact tax, and the UI says so in words — this is
            what stops an approximate figure from looking as solid as an exact one.
        reasons:
          type: array
          description: >-
            Up to 3 sentences naming the figures that moved this row furthest from neutral.
            NEGATIVE contributors are included deliberately: a recommendation that lists only
            what it liked is advertising, not advice.
          items:
            type: object
            properties:
              id: { type: string, enum: [tax, rent, commute, center, lake] }
              label: { type: string }
              text: { type: string, description: Human sentence containing the real figure. }
              positive: { type: boolean, description: False when this criterion pushed the row DOWN. }

    FavorableRankingError:
      type: object
      properties:
        bfsId: { type: integer }
        name: { type: string }
        error: { type: string }

    FavorableRanking:
      type: object
      required: [year, current, items, errors]
      properties:
        year: { type: integer }
        current:
          type: object
          properties:
            bfsId: { type: [integer, 'null'] }
            name: { type: [string, 'null'] }
            total: { type: [number, 'null'], description: Null when the current home's own tax isn't cached/available yet. }
            rooms: { type: [number, 'null'], description: The room count every item's `rent` figure was looked up for. }
            rent:
              type: ['object', 'null']
              description: The current home's own expected rent, same shape and same basis rules as FavorableItem.rent.
        items:
          type: array
          description: >-
            Municipalities cheaper than or within the ±1000 CHF/yr "similar" band of the
            current home, sorted ascending by delta (never the ones actually worse).
          items: { $ref: '#/components/schemas/FavorableItem' }
        errors:
          type: array
          items: { $ref: '#/components/schemas/FavorableRankingError' }
        unavailable: { type: boolean, description: True when even the current home's own tax lookup failed. }
        partial: { type: boolean, description: True whenever cache-only mode was used (always, for this route) and the cache may be incomplete. }
        cachedCount: { type: integer, description: How many of totalCandidates were already cache-warm. }
        totalCandidates: { type: integer }
        warming: { type: boolean, description: Present when partial and incomplete — whether a background warm job is (now) in flight for these exact figures. }
      example:
        year: 2026
        current: { bfsId: 1346, name: "Siebnen SZ", total: 14550 }
        items:
          - bfsId: 1321
            name: "Feusisberg"
            canton: SZ
            total: 9998
            delta: -4552
            match: 78
            confidence: 0.62
            reasons:
              - { id: tax, label: Income tax, text: "Saves CHF 4'552/yr in tax", positive: true }
              - { id: commute, label: Commute, text: "11 km closer to Zürich HB (straight line)", positive: true }
        errors: []
        partial: true
        cachedCount: 210
        totalCandidates: 278
        warming: true

    MunicipalityDetailErrorEntry:
      type: object
      properties:
        year: { type: integer }
        bfsId: { type: integer }
        error: { type: string }

    MunicipalityDetailHistoryEntry:
      type: object
      properties:
        year: { type: integer }
        total: { type: number }
        currentTotal: { type: number }
        delta: { type: number, description: total − currentTotal for that year. }
        pctOfGross: { type: number, description: total as a fraction of incomeAnnual. }
        rates: { $ref: '#/components/schemas/TaxRates' }

    MunicipalityDetail:
      type: object
      required: [year, years, municipality, current, tax, shares, effective, history, errors]
      properties:
        year: { type: integer }
        years: { type: integer, description: How many years of history were requested/returned. }
        municipality:
          type: object
          properties:
            bfsId: { type: integer }
            name: { type: string }
            canton: { type: string }
            zips:
              type: array
              items: { type: string }
        current:
          type: object
          properties:
            bfsId: { type: integer }
            name: { type: [string, 'null'] }
            total: { type: [number, 'null'] }
        tax: { $ref: '#/components/schemas/TaxResult' }
        delta: { type: [number, 'null'], description: This municipality's tax total minus the current home's, for `year`. }
        shares:
          type: object
          description: Each component as a fraction of tax.total for `year`.
          properties:
            federal: { type: number }
            cantonal: { type: number }
            municipal: { type: number }
            church: { type: number }
            personal: { type: number }
        effective:
          type: object
          properties:
            onGross: { type: number, description: tax.total / gross income. }
            onTaxable: { type: number, description: tax.total / taxableIncomeCanton. }
        history:
          type: array
          items: { $ref: '#/components/schemas/MunicipalityDetailHistoryEntry' }
        errors:
          type: array
          items: { $ref: '#/components/schemas/MunicipalityDetailErrorEntry' }

    CommuteInfo:
      type: object
      required: [minutes, transfers, legs]
      properties:
        minutes: { type: number, description: Median duration across the next few connections. }
        transfers: { type: [integer, 'null'] }
        legs:
          type: array
          items: { type: string }
          description: "Line designations of the median connection's legs, e.g. [\"S13\", \"IR75\"]."
      example: { minutes: 41, transfers: 1, legs: ["S13", "IR75"] }

    Commute:
      description: >-
        A candidate listing's commute record — commuteMinutes()'s three fields plus the
        fare-zone cost prediction. Null on the ListingRecord when the candidate has no
        coordinates and the commute-time fetch also failed.
      type: object
      properties:
        minutes: { type: [number, 'null'] }
        transfers: { type: [integer, 'null'] }
        legs:
          type: array
          items: { type: string }
        predictedSbbCostMonthly: { type: [number, 'null'] }
        predictedCarCostMonthly: { type: [number, 'null'] }
        predictedNationalCostMonthly: { type: [number, 'null'] }
        transitMethod: { type: [string, 'null'], description: Which regime priced the estimate (Regional Fare Network zones vs SBB national point-to-point). }
        transitZones: { type: [number, 'null'] }
        transitKm: { type: [number, 'null'] }

    HomeCommute:
      description: Same "why is this the estimate" detail as Commute, for the current-home side. Null when an explicit commuteCostMonthly short-circuited the auto-calc.
      type: object
      nullable: true
      properties:
        predictedSbbCostMonthly: { type: [number, 'null'] }
        predictedCarCostMonthly: { type: [number, 'null'] }
        predictedNationalCostMonthly: { type: [number, 'null'] }
        transitMethod: { type: [string, 'null'] }

    Candidate:
      type: object
      description: A normalized listing, from flatfox, homegate, or a manual entry. bfsId/municipality are filled in during ingest.
      required: [source, zip, city]
      properties:
        source: { type: string, enum: [flatfox, homegate, manual] }
        sourceId: { type: [string, 'null'] }
        url: { type: [string, 'null'], format: uri }
        title: { type: [string, 'null'] }
        street: { type: [string, 'null'] }
        zip: { type: string, description: "String even though it looks numeric — flatfox stringifies it." }
        city: { type: [string, 'null'] }
        rooms: { type: [number, 'null'] }
        surface: { type: [number, 'null'] }
        floor: { type: [string, 'null'] }
        rentNet: { type: [number, 'null'] }
        rentCharges: { type: [number, 'null'] }
        rentGross: { type: [number, 'null'] }
        lat: { type: [number, 'null'] }
        lng: { type: [number, 'null'] }
        images:
          type: array
          items: { type: string, format: uri }
        bfsId: { type: [integer, 'null'] }
        municipality: { type: [string, 'null'] }
        category:
          description: >-
            What the PORTAL stated about the kind of listing, or null when it stated nothing
            recognisable (absent, never guessed). Flatfox states this structurally
            (object_type/object_category enums, is_swap/is_temporary/is_furnished booleans);
            Homegate and pasted pages do not, and get `categoryHints` instead. A boolean the
            portal did not send stays null — silence is not a denial.
          oneOf:
            - type: 'null'
            - type: object
              properties:
                kind: { type: [string, 'null'], enum: [home, room, other, null] }
                tenure: { type: [string, 'null'], enum: [rent, sell, null] }
                swap: { type: [boolean, 'null'] }
                temporary: { type: [boolean, 'null'] }
                furnished: { type: [boolean, 'null'] }
        categoryHints:
          type: array
          items: { type: string, enum: [possible-swap, possible-temporary, possible-sublet] }
          description: >-
            A deterministic keyword scan of the listing's own wording, for portals that state
            nothing structurally. Every value is prefixed `possible-` so a HINT can never be
            string-matched into a fact. Deliberately permissive — "no subletting" still hints.
      example:
        source: flatfox
        sourceId: "85823796"
        url: "https://flatfox.ch/en/flat/some-listing/85823796/"
        title: "3.5 rooms, Schindellegi"
        street: "Musterstrasse 1"
        zip: "8834"
        city: Schindellegi
        rooms: 3.5
        surface: 100
        floor: "2"
        rentNet: 1750
        rentCharges: 180
        rentGross: 1930
        lat: 47.187
        lng: 8.699
        images: ["https://cdn.flatfox.ch/img/1.jpg"]
        bfsId: 1321
        municipality: Feusisberg

    ComparisonMonthly:
      type: object
      properties:
        rentDelta: { type: number }
        parkingDelta: { type: number }
        electricityDelta: { type: number }
        extraDelta: { type: number }
        commuteDelta: { type: number }
        totalDelta: { type: number }

    ComparisonAnnual:
      type: object
      properties:
        costDelta: { type: number, description: totalDelta × 12. }
        taxDelta: { type: number, description: (candidate tax − current tax) + any manual taxAdjustmentAnnual. }
        netDelta: { type: number, description: "costDelta + taxDelta. Sign convention: candidate − current, negative = moving saves money." }

    Comparison:
      type: object
      description: Output of the pure server/compare.js compare().
      required: [monthly, annual, oneOff, verdict, caveats]
      properties:
        monthly: { $ref: '#/components/schemas/ComparisonMonthly' }
        annual: { $ref: '#/components/schemas/ComparisonAnnual' }
        oneOff:
          type: object
          properties:
            total: { type: number, description: Sum of settings.searchCosts, never folded into netDelta. }
            amortizedMonthly24: { type: number }
        verdict:
          type: object
          properties:
            label: { type: string, enum: [cheaper, similar, pricier] }
            color: { type: string, enum: [good, warn, bad] }
        caveats:
          type: array
          items: { type: string }
      example:
        monthly: { rentDelta: 390, parkingDelta: -100, electricityDelta: 0, extraDelta: 0, commuteDelta: -40, totalDelta: 250 }
        annual: { costDelta: 3000, taxDelta: -4552, netDelta: -1552 }
        oneOff: { total: 17, amortizedMonthly24: 0.71 }
        verdict: { label: cheaper, color: good }
        caveats: ["Rent deduction on taxes not modeled — verify canton-specific deductions (e.g. commute deduction changes) manually."]

    Quality:
      type: object
      description: Output of server/quality.js qualityScore(), plus the raw slider inputs and commute minutes it was computed from.
      required: [score, components, view, green, commuteMinutes]
      properties:
        score: { type: integer, minimum: 0, maximum: 100 }
        components:
          type: object
          properties:
            commute: { type: [integer, 'null'] }
            center: { type: [integer, 'null'] }
            lake: { type: [integer, 'null'] }
            view: { type: integer }
            green: { type: integer }
        view: { type: number, minimum: 0, maximum: 10, description: Raw slider input (defaults to 5). }
        green: { type: number, minimum: 0, maximum: 10, description: Raw slider input (defaults to 5). }
        commuteMinutes: { type: [number, 'null'] }

    ValueCompare:
      type: object
      description: >-
        Output of server/value.js valueCompare() — the value-for-money axis, answering
        "is the extra rent buying proportional space?". Recomputed on every PATCH, since
        it derives from `comparison`, `quality` and `overrides.features`.
      required: [size, features, perM2, valueScore, components, caveats]
      properties:
        size:
          type: object
          properties:
            currentRooms: { type: [number, 'null'] }
            candidateRooms: { type: [number, 'null'] }
            currentSurface: { type: [number, 'null'] }
            candidateSurface: { type: [number, 'null'] }
            currentSurfaceEstimated:
              type: boolean
              description: True when the area was derived from the room count rather than stated.
            candidateSurfaceEstimated: { type: boolean }
            roomsDelta: { type: [number, 'null'] }
            surfaceDelta: { type: [number, 'null'] }
            surfacePct: { type: [number, 'null'], description: Percentage change in floor area, candidate vs current. }
        features:
          type: object
          properties:
            current: { $ref: '#/components/schemas/ListingFeatures' }
            candidate: { $ref: '#/components/schemas/ListingFeatures' }
            floorDelta:
              type: [number, 'null']
              description: Reported for context only — floor is deliberately not scored.
            balconyScore: { type: [number, 'null'] }
            renovationScore: { type: [integer, 'null'] }
        perM2:
          type: object
          description: Monthly CHF per m², rent (net + charges) only — parking and electricity do not scale with floor area.
          properties:
            current: { type: [number, 'null'] }
            candidate: { type: [number, 'null'] }
            delta: { type: [number, 'null'] }
        fairPriceMonthly:
          type: [number, 'null']
          description: What the candidate would cost at the current home's CHF/m².
        spaceAdjustedMonthly:
          type: [number, 'null']
          description: >-
            THE headline number. candidate − its own fair price, so the app-wide sign
            convention holds: negative means cheaper per m² than today (good/green).
        spaceAdjustedAnnual: { type: [number, 'null'] }
        valueScore:
          type: integer
          minimum: 0
          maximum: 100
          description: Sortable composite (History's "Best value"). Null components redistribute their weight.
        components:
          type: object
          properties:
            money: { type: integer }
            space: { type: [integer, 'null'] }
            quality: { type: [integer, 'null'] }
            features: { type: [integer, 'null'] }
        verdict:
          type: ['object', 'null']
          description: Null when there is no size on either side to judge.
          properties:
            label: { type: string, enum: [better value, fair, worse value] }
            color: { type: string, enum: [good, warn, bad] }
        caveats: { type: array, items: { type: string } }
      example:
        size: { currentRooms: 3.5, candidateRooms: 4.5, currentSurface: 70, candidateSurface: 110, currentSurfaceEstimated: false, candidateSurfaceEstimated: false, roomsDelta: 1, surfaceDelta: 40, surfacePct: 57.14 }
        perM2: { current: 20, candidate: 18.18, delta: -1.82 }
        fairPriceMonthly: 2200
        spaceAdjustedMonthly: -200
        spaceAdjustedAnnual: -2400
        valueScore: 71
        verdict: { label: better value, color: good }
        caveats: []

    ListingFeatures:
      type: object
      description: Per-listing feature inputs. `floor` is scraped by both portals; balcony and renovationYear come only from the Settings form / the Adjust panel.
      properties:
        balcony: { type: [boolean, 'null'], description: Tri-state — null means "not stated", distinct from false. }
        floor: { type: [integer, 'null'], description: 0 = ground floor. }
        renovationYear: { type: [integer, 'null'] }

    Overrides:
      type: object
      properties:
        parking: { type: number, default: 0 }
        electricity: { type: number, default: 0 }
        extraBills: { type: number, default: 0 }
        commuteCostMonthly: { type: number, description: Defaults to the fare-zone-predicted monthly cost, not 0. }
        taxAdjustmentAnnual: { type: number, default: 0 }
        features:
          type: object
          description: >-
            User-entered overrides for the candidate, merged one level deep by PATCH so
            setting one field does not clear the others. Each falls back to the scraped
            listing value when absent (server/value.js valueCandidate()).
          properties:
            rooms: { type: [number, 'null'] }
            surface: { type: [number, 'null'], description: Override for a listing that states no m². }
            floor: { type: [integer, 'null'] }
            renovationYear: { type: [integer, 'null'] }
            balcony: { type: [boolean, 'null'] }

    ListingRecord:
      type: object
      description: The full evaluation record — returned by ingest, PATCH, and both GET listings routes. Persisted to data/listings.json only for authenticated requests.
      required: [id, createdAt, profileId, currentHome, weights, candidate, overrides, qualityInputs, quality, tax, comparison, errors]
      properties:
        id: { type: string, format: uuid }
        createdAt: { type: string, format: date-time }
        profileId: { type: [string, 'null'], description: Null for a guest's (unpersisted) result. }
        currentHome:
          $ref: '#/components/schemas/CurrentHome'
        homeCommute: { $ref: '#/components/schemas/HomeCommute' }
        weights: { $ref: '#/components/schemas/Weights' }
        candidate: { $ref: '#/components/schemas/Candidate' }
        overrides: { $ref: '#/components/schemas/Overrides' }
        # Deliberately NOT in `required` above: every record persisted before value.js
        # existed lacks it, and the UI null-checks rather than migrating.
        value: { $ref: '#/components/schemas/ValueCompare' }
        qualityInputs:
          type: object
          properties:
            view: { type: [number, 'null'] }
            green: { type: [number, 'null'] }
        commute:
          oneOf:
            - { $ref: '#/components/schemas/Commute' }
            - type: 'null'
        quality: { $ref: '#/components/schemas/Quality' }
        tax:
          type: object
          properties:
            year: { type: integer }
            current: { $ref: '#/components/schemas/TaxResult' }
            candidate: { $ref: '#/components/schemas/TaxResult' }
        comparison: { $ref: '#/components/schemas/Comparison' }
        errors:
          type: array
          items: { type: string }
          description: Human-readable degradation notices (failed commute fetch, failed geocode, stale-scrape fallback, etc) — never silently dropped.
        variantOf:
          type: [string, 'null']
          description: >-
            Set only on a scenario created via POST /api/listing-variants — the id of the
            ROOT listing it was forked from. Absent on ordinary listings. History groups one
            level deep on this.
        label:
          type: [string, 'null']
          description: A scenario's name, set with `variantOf` and only by the variant route.
        warnings:
          type: array
          items: { type: string }
          description: >-
            Comparability caveats — this listing is a room, a swap, a temporary let or a
            sale, so the delta compares two different kinds of thing. Separate from `errors`
            on purpose: nothing FAILED, the figures are real, and burying "this is a room"
            among fetch failures is how it would go unread. Derived from
            `candidate.category`/`categoryHints` by server/listingCategory.js.

    IngestRequest:
      type: object
      description: >-
        Provide EITHER input, url, pastedHtml, OR manual. profile/profileId only matter
        for a guest caller.
      properties:
        input:
          type: string
          description: >-
            The hero-input contract: one free-form string, sniffed server-side into a
            listing URL, a pasted page (plain text / page source / the bookmarklet's
            JSON), or free text ("3.5 rooms near Zug under 2500, quiet"). When present
            (and `url` is not also set), this supersedes url/pastedHtml/manual — see the
            operation description for the resulting response shapes. Mutually
            informative with `sourceUrl` only in the same way pastedHtml is: a page
            sniffed out of `input` may be paired with `sourceUrl`.
        bfsId:
          type: integer
          description: >-
            The answer to an `ambiguous-place` search: the municipality the user PICKED.
            Only meaningful alongside `input`. When present the server skips place
            resolution entirely and searches this municipality — an explicit statement,
            never re-derived from the same ambiguous words that produced the question.
            An unknown id is a 400.
        url: { type: string, format: uri, description: A flatfox.ch or homegate.ch listing URL. }
        pastedHtml:
          type: string
          maxLength: 4194304
          description: >-
            The user-assisted Homegate path. Homegate's detail pages are behind a DataDome bot
            wall that refuses this server on every transport, but the person clicking "Evaluate"
            is a human whose own browser loads the page fine — so they hand us the page instead.
            Accepts either the full page source or the compact JSON blob the bookmarklet (returned
            as `bookmarklet` in the 422 body) copies out of an already-open tab. Parsed by the SAME
            pure parsers as the scraped path. This carries listing DATA only, never a CAPTCHA token:
            DataDome binds a token to the solver's IP, so a relayed one would be invalid anyway.
            Bounded at 4 MB and never trusted.
        sourceUrl:
          type: string
          format: uri
          description: Optional companion to pastedHtml — the listing URL the pasted page came from.
        manual:
          type: object
          properties:
            title: { type: string }
            street: { type: string }
            zip: { type: string }
            city: { type: string }
            rooms: { type: number }
            surface: { type: number }
            floor: { type: string }
            rentNet: { type: number }
            rentCharges: { type: number }
            lat: { type: number }
            lng: { type: number }
            images:
              type: array
              items: { type: string, format: uri }
        overrides: { $ref: '#/components/schemas/Overrides' }
        profile:
          description: Guest mode ONLY — required when no session is present, ignored (along with profileId) when one is.
          allOf:
            - { $ref: '#/components/schemas/Profile' }

    IngestPlace:
      type: object
      description: >-
        A resolved place, returned only by the `input`-driven ingest responses.
        `place` and `gemeinde` are deliberately different things: `place` is the
        village/locality name as typed or scraped ("siebnen"), `gemeinde` is the Swiss
        municipality that actually owns tax/commute data ("Schübelbach") — collapsing
        the two is how an exact combo fare silently degrades to a km-tier guess.
      properties:
        place: { type: [string, 'null'] }
        gemeinde: { type: [string, 'null'], description: The resolved municipality name. }
        bfsId: { type: [integer, 'null'] }
        canton: { type: [string, 'null'] }
        zip: { type: [string, 'null'] }
        ambiguous:
          type: boolean
          description: >-
            True when the ZIP spans several Gemeinden (8854 spans three) and the server
            picked the one most of the address rows agreed on. `candidates` lists all of
            them rather than silently resolving to the first hit.
        candidates:
          type: array
          items: { type: object }

    IngestIntent:
      type: object
      description: >-
        The structured search intent parsed from free text (server/intent.js
        parseIntent). Every field is nullable/possibly-empty — an unstated criterion is
        left null, never guessed. Never carries a `tax` or `rent` weight; see
        recommend-engine's `inferWeights` ceiling, which this reads keys from only.
      properties:
        rooms: { type: [number, 'null'] }
        roomsMin: { type: [number, 'null'] }
        budgetMax: { type: [number, 'null'] }
        surfaceMin: { type: [number, 'null'] }
        placeHints: { type: array, items: { type: string } }
        keywords: { type: array, items: { type: string } }
        raw: { type: string }

    IngestSuggestions:
      type: object
      description: >-
        `kind: 'suggestions'` — the portal walled the fetch (Homegate/DataDome, most
        often). Returned as a 200, not a 422: alternative listings found near the same
        slug are an answer, not a failure. `bookmarklet`/`partial` are the same paste
        affordance the legacy 422 offered, now a secondary field rather than the only
        result.
      required: [kind, reason, assisted, bookmarklet, partial, classification, candidates]
      properties:
        kind: { type: string, enum: [suggestions] }
        reason: { type: string, description: The underlying wall error message. }
        assisted: { type: boolean }
        bookmarklet: { type: string, description: The bookmarklet source, or empty string. }
        partial: { type: object, description: Best-effort partial listing fields recovered before the failure. }
        classification: { type: string, description: "server/scrapeDiagnostics.js's classification, e.g. blocked_datadome." }
        candidates:
          type: array
          items: { type: object }
          description: Listings found via server/sources/*Search.js near the walled link's slug. A browsing aid, never fed into a comparison.
        place: { $ref: '#/components/schemas/IngestPlace' }
        intent: { $ref: '#/components/schemas/IngestIntent' }
        rentStats: { type: [object, 'null'] }

    IngestSearch:
      type: object
      description: >-
        `kind: 'search'` — `body.input` sniffed as free text. `items` are soft-ranked
        (never filtered) against `intent` the same way /api/listing-recommendations
        already ranks: a stated budget/rooms criterion that nothing matches still
        returns the whole market, reordered.
      required: [kind, intent, place, items]
      properties:
        kind: { type: string, enum: [search] }
        intent: { $ref: '#/components/schemas/IngestIntent' }
        place:
          allOf:
            - { $ref: '#/components/schemas/IngestPlace' }
          nullable: true
        items:
          type: array
          items: { type: object }
        rentStats: { type: [object, 'null'] }
        errors: { type: array, items: { type: string } }
        reason:
          type: string
          enum: [no-place, ambiguous-place]
          description: >-
            `no-place`: the WHAT was understood but the WHERE was not — place is null and
            items is empty. `ambiguous-place`: the name means several municipalities
            ("Pfäffikon" is a Gemeinde in ZH and a locality of Freienbach in SZ) — place
            is null, items is empty, and `placeChoices` carries the options. No listings
            are returned with the question: showing one canton's market underneath it
            would answer it on the user's behalf.
        placeChoices:
          type: array
          description: Present only with reason `ambiguous-place`. Re-post `input` with the chosen `bfsId`.
          items:
            type: object
            properties:
              bfsId: { type: integer }
              gemeinde: { type: string, description: The municipality the tax delta would be computed for. }
              canton: { type: [string, 'null'] }
              place: { type: [string, 'null'], description: The locality name, which often differs from the Gemeinde. }
              zip: { type: [string, 'null'] }

    IngestManual:
      type: object
      description: >-
        `kind: 'manual'` — the manual-entry (simulation) form, prefilled from `partial`.
        Two inputs produce it. Either `body.input` was sniffed as a pasted page that could
        only be partly read (`classification` is then a scrape classification such as
        `incomplete` or `shape_changed`), or it was sniffed as a typed STREET ADDRESS and
        resolved against the federal address register (`classification: address`, `reason:
        address`, and `place` describes what resolved). An address names one flat, so it
        opens the simulation rather than the municipality's rental market; an address that
        does not resolve degrades to `IngestSearch` instead.
      required: [kind, partial, reason, classification]
      properties:
        kind: { type: string, enum: [manual] }
        partial: { type: object, description: "Best-effort partial listing fields. For an address: street/zip/city, where zip and city come from the register and street from the typed text. Coordinates are deliberately not included — the form cannot show or edit them, so they would go stale if the user corrected the ZIP." }
        reason: { type: string, description: "The underlying parse-failure message, or 'address'." }
        classification: { type: string, description: "server/scrapeDiagnostics.js's classification (e.g. shape_changed, incomplete), or 'address' for a resolved street address." }
        place: { type: object, description: "Address inputs only. The resolved place — same shape as IngestSearch's `place`." }

    Ad:
      type: object
      description: Public-facing ad shape — GET /api/ads strips everything operator-only.
      required: [id, headline, body, image, label]
      properties:
        id: { type: string }
        headline: { type: string }
        body: { type: string }
        image: { type: [string, 'null'], description: A data:image/... URI, or null. }
        label: { type: string, example: Ad }

    AdAdmin:
      type: object
      description: Full ad record as stored/returned to an admin.
      allOf:
        - { $ref: '#/components/schemas/Ad' }
        - type: object
          properties:
            enabled: { type: boolean }
            kind: { type: string, enum: [house, network], description: Only "house" (first-party) actually renders. }
            href: { type: string, format: uri }
            weight: { type: number, default: 1 }
            startsAt: { type: [string, 'null'], format: date }
            endsAt: { type: [string, 'null'], format: date }

    AdAdminInput:
      type: object
      description: Input shape for PUT /api/admin/ads — same fields as AdAdmin, all optional (server fills in defaults/ids).
      properties:
        id: { type: string }
        enabled: { type: boolean }
        kind: { type: string, enum: [house, network] }
        headline: { type: string, maxLength: 120 }
        body: { type: string, maxLength: 300 }
        label: { type: string, maxLength: 24 }
        href: { type: string, description: Must start with http:// or https:// if present. }
        image: { type: string, description: A base64 data:image/(png|jpeg|gif|webp) URI, capped around 200 KB. SVG is rejected — it can carry a script payload. }
        weight: { type: number }
        startsAt: { type: [string, 'null'], format: date }
        endsAt: { type: [string, 'null'], format: date }

    Sponsor:
      type: object
      description: >-
        A sponsored-placement record (server/listingSponsors.js). A listing whose
        `source` matches an active, enabled sponsor is reordered to the front of
        results (highest weight wins, deterministic — never an auction) and stamped
        with `sponsored: true` + `sponsorLabel` on the way out; see toPublicListing.
      required: [id, enabled, source, weight, label]
      properties:
        id: { type: string }
        enabled: { type: boolean }
        source: { type: string, description: "Matches a listing's `source` field, e.g. flatfox, homegate." }
        weight: { type: number, default: 1, description: Higher wins when a source has more than one active sponsor. }
        label: { type: string, default: Sponsored, example: Sponsored }
        startsAt: { type: [string, 'null'], format: date }
        endsAt: { type: [string, 'null'], format: date, description: Inclusive of the whole day when given as a bare date. }

    SponsorInput:
      type: object
      description: Input shape for PUT /api/admin/listing-sponsors — same fields as Sponsor, all optional except source (server fills in defaults/ids).
      properties:
        id: { type: string }
        enabled: { type: boolean }
        source: { type: string }
        weight: { type: number }
        label: { type: string, maxLength: 24 }
        startsAt: { type: [string, 'null'], format: date }
        endsAt: { type: [string, 'null'], format: date }

    BackgroundPhoto:
      type: object
      properties:
        url: { type: [string, 'null'], format: uri }
        color: { type: [string, 'null'], description: Dominant color hex, for a loading placeholder. }
        alt: { type: string }
        author: { type: [string, 'null'] }
        authorUrl: { type: [string, 'null'], format: uri, description: UTM-tagged Unsplash profile link. }
        photoUrl: { type: [string, 'null'], format: uri, description: UTM-tagged Unsplash photo link. }

    BackgroundResponse:
      type: object
      required: [date, photo]
      properties:
        date: { type: string, format: date, description: "UTC day key (YYYY-MM-DD) the rotation used." }
        photo:
          oneOf:
            - { $ref: '#/components/schemas/BackgroundPhoto' }
            - type: 'null'

    LogEvent:
      type: object
      description: One entry from the in-memory/logs/logbook.json event ring buffer (capped at 2000).
      required: [at, kind]
      properties:
        at: { type: integer, description: Epoch milliseconds. }
        kind: { type: string, enum: [request, auth, ratelimit, error, ad] }
      additionalProperties: true
      example: { at: 1785660608000, kind: request, method: GET, path: "/api/municipalities", status: 200, ms: 4 }

    ExportData:
      type: object
      description: GDPR export payload — everything Zügle stores about one profile.
      required: [generatedAt, notice, profile, listings, settings]
      properties:
        generatedAt: { type: string, format: date-time }
        notice: { type: string }
        profile:
          oneOf:
            - { $ref: '#/components/schemas/Profile' }
            - type: 'null'
        listings:
          type: array
          items: { $ref: '#/components/schemas/ListingRecord' }
        settings: { $ref: '#/components/schemas/Settings' }

    DeleteResult:
      type: object
      required: [listingsDeleted, taxCacheEntriesPurged, profileReset]
      properties:
        listingsDeleted: { type: integer }
        taxCacheEntriesPurged: { type: integer }
        logEventsPurged: { type: integer, description: Absent when the profile id wasn't found at all (a no-op erasure). }
        profileReset: { type: boolean }
