openapi: 3.1.0

info:
  title: walter2 API
  version: "1.0.0"
  description: |
    Offene REST-Schnittstelle des Immobilienmarktplatzes walter2.

    Die API ist die primäre Schnittstelle der Plattform: Weboberfläche und die
    nativen Apps sind gleichberechtigte Clients dieser Endpunkte. Jede Funktion
    existiert hier, bevor sie in einer Oberfläche auftaucht.

    ## Authentifizierung

    Anmeldung über `POST /auth/login` liefert ein Bearer-Token (Laravel Sanctum).
    Das Token gehört in den Header `Authorization: Bearer <token>`.

    ## Bereiche

    - **Öffentlich** — Objektsuche und Stammdaten, ohne Anmeldung
    - **Portal** (`/portal/…`) — Vermieter und Verwaltungen, mandantengetrennt
    - **Maschinenzugang** (`/integration/…`) — für Verwaltersoftware, Anmeldung
      über einen API-Schlüssel im Header `X-API-Key` statt über ein Nutzerkonto

    ## Webhooks

    Statt zu pollen lässt sich ein Ziel hinterlegen, das walter2 bei Ereignissen
    aufruft (`property.created`, `property.updated`, `inquiry.received`,
    `inquiry.status_changed`, `match.scored`).

    Jede Zustellung trägt im Header `X-Walter2-Signature` einen HMAC-SHA256 über
    den Rumpf, gebildet mit dem beim Anlegen ausgegebenen Geheimnis. **Diese
    Signatur immer prüfen** — sonst kann jeder, der die Zieladresse kennt,
    beliebige Ereignisse vortäuschen.

    Nach zehn Fehlversuchen in Folge wird ein Ziel abgeschaltet.

    ## Mandantentrennung

    Unter `/portal/…` sieht ein Konto ausschließlich Daten des eigenen Mandanten.
    Die `tenant_id` wird serverseitig gesetzt und nie vom Client übernommen.

servers:
  - url: https://walter2.app/api/v1
    description: Produktivsystem
  - url: http://167.233.249.245/api/v1
    description: Direktzugriff über die IP, solange der DNS-Eintrag fehlt

tags:
  - name: Authentifizierung
  - name: Stammdaten
  - name: Objektsuche
  - name: Objektverwaltung
  - name: Anfragen
  - name: Maschinenzugang

security:
  - BearerAuth: []

paths:

  /health:
    get:
      tags: [Stammdaten]
      summary: Verfügbarkeit prüfen
      security: []
      responses:
        "200":
          description: Dienst läuft
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  time: { type: string, format: date-time }

  /auth/register:
    post:
      tags: [Authentifizierung]
      summary: Konto anlegen
      description: |
        `account_type` entscheidet über die Kontoart. `interessent` legt ein
        plattformweites Konto samt leerer Bewerbungsmappe an, `vermieter` und
        `verwaltung` zusätzlich einen Mandanten.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [account_type, first_name, last_name, email, password, password_confirmation]
              properties:
                account_type:
                  type: string
                  enum: [interessent, vermieter, verwaltung]
                first_name: { type: string }
                last_name: { type: string }
                email: { type: string, format: email }
                password: { type: string, format: password }
                password_confirmation: { type: string, format: password }
                phone: { type: string, nullable: true }
                company_name:
                  type: string
                  nullable: true
                  description: Pflicht bei `account_type = verwaltung`
                vat_id: { type: string, nullable: true }
      responses:
        "201":
          description: Konto angelegt
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  token: { type: string }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /auth/login:
    post:
      tags: [Authentifizierung]
      summary: Anmelden
      description: |
        Nach fünf Fehlversuchen je Adresse und IP wird die Anmeldung vorübergehend
        gesperrt. Unbekanntes Konto und falsches Passwort liefern dieselbe Meldung,
        damit sich registrierte Adressen nicht ermitteln lassen.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
                device_name:
                  type: string
                  description: Gerätebezeichnung, erscheint in der Tokenübersicht
      responses:
        "200":
          description: Angemeldet
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  token: { type: string }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /auth/me:
    get:
      tags: [Authentifizierung]
      summary: Angemeldetes Konto abrufen
      responses:
        "200":
          description: Konto
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/NichtAngemeldet" }

  /auth/logout:
    post:
      tags: [Authentifizierung]
      summary: Aktuelles Gerät abmelden
      responses:
        "200": { description: Abgemeldet }
        "401": { $ref: "#/components/responses/NichtAngemeldet" }

  /auth/logout-all:
    post:
      tags: [Authentifizierung]
      summary: Alle Geräte abmelden
      responses:
        "200": { description: Überall abgemeldet }
        "401": { $ref: "#/components/responses/NichtAngemeldet" }

  /property-types:
    get:
      tags: [Stammdaten]
      summary: Objektarten
      description: |
        Liefert je Objektart das `criteria_schema` — die Liste der Ausstattungs-
        merkmale, die für sie gelten. Clients bauen ihre Formulare daraus auf,
        statt Felder fest einzubauen. Dadurch erscheint eine neue Objektart ohne
        App-Update.
      security: []
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PropertyType" }

  /listing-types:
    get:
      tags: [Stammdaten]
      summary: Vermarktungsarten (Miete, Kauf)
      security: []
      responses:
        "200":
          description: Liste
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ListingType" }

  /properties:
    get:
      tags: [Objektsuche]
      summary: Veröffentlichte Objekte suchen
      description: |
        Öffentliche Suche ohne Anmeldung. Entwürfe sind nicht enthalten. Die
        genaue Adresse erscheint nur, wenn der Vermieter sie freigegeben hat.
      security: []
      parameters:
        - { name: listing_type, in: query, schema: { type: string, example: miete } }
        - { name: property_type, in: query, schema: { type: string, example: wohnung } }
        - { name: city, in: query, schema: { type: string } }
        - { name: postal_code, in: query, schema: { type: string } }
        - { name: price_min, in: query, schema: { type: number } }
        - { name: price_max, in: query, schema: { type: number } }
        - { name: rooms_min, in: query, schema: { type: number } }
        - { name: rooms_max, in: query, schema: { type: number } }
        - { name: living_area_min, in: query, schema: { type: number } }
        - { name: available_from, in: query, schema: { type: string, format: date } }
        - { name: sort, in: query, schema: { type: string, enum: [preis, flaeche, zimmer] } }
        - { name: direction, in: query, schema: { type: string, enum: [asc, desc] } }
        - { name: per_page, in: query, schema: { type: integer, maximum: 50, default: 20 } }
      responses:
        "200": { $ref: "#/components/responses/Objektliste" }

  /properties/{id}:
    get:
      tags: [Objektsuche]
      summary: Veröffentlichtes Objekt abrufen
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "404":
          description: |
            Nicht gefunden. Entwürfe liefern bewusst ebenfalls 404 — ob unter
            dieser Kennung ein unveröffentlichtes Objekt existiert, geht
            Unbeteiligte nichts an.

  /portal/properties:
    get:
      tags: [Objektverwaltung]
      summary: Eigene Objekte auflisten
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [entwurf, veroeffentlicht, reserviert, vermarktet, archiviert] } }
        - { name: property_type_id, in: query, schema: { type: string, format: uuid } }
        - { name: listing_type_id, in: query, schema: { type: string, format: uuid } }
        - { name: suche, in: query, schema: { type: string }, description: Titel, Ort oder interne Bezeichnung }
        - { name: per_page, in: query, schema: { type: integer, maximum: 100, default: 25 } }
      responses:
        "200": { $ref: "#/components/responses/Objektliste" }
        "403": { $ref: "#/components/responses/KeineBerechtigung" }
    post:
      tags: [Objektverwaltung]
      summary: Objekt anlegen
      description: |
        Das Objekt entsteht als Entwurf. `tenant_id` wird serverseitig gesetzt.
        `hard_criteria` wird gegen das `criteria_schema` der gewählten Objektart
        geprüft — unbekannte Merkmale werden abgewiesen.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyEingabe" }
      responses:
        "201": { $ref: "#/components/responses/Objekt" }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /portal/properties/{property}:
    parameters:
      - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Objektverwaltung]
      summary: Eigenes Objekt abrufen
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "403": { $ref: "#/components/responses/KeineBerechtigung" }
    patch:
      tags: [Objektverwaltung]
      summary: Objekt ändern
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyEingabe" }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "422": { $ref: "#/components/responses/Validierungsfehler" }
    delete:
      tags: [Objektverwaltung]
      summary: Objekt löschen
      description: Soft Delete — bestehende Anfragen bleiben nachvollziehbar.
      responses:
        "204": { description: Gelöscht }
        "403": { $ref: "#/components/responses/KeineBerechtigung" }

  /portal/properties/{property}/publish:
    post:
      tags: [Objektverwaltung]
      summary: Objekt veröffentlichen
      description: |
        Prüft vorher die Pflichtangaben. Für Wohn- und Gewerbeobjekte sind die
        Kennwerte des Energieausweises nach GEG § 87 verpflichtend; alternativ
        ist eine begründete Ausnahme anzugeben. Stellplätze, Garagen und
        Grundstücke sind ausgenommen.
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "422":
          description: Pflichtangaben fehlen
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Validierungsfehler" }

  /portal/properties/{property}/unpublish:
    post:
      tags: [Objektverwaltung]
      summary: Objekt zurückziehen
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }

  /portal/properties/{property}/readiness:
    get:
      tags: [Objektverwaltung]
      summary: Prüfen, was zur Veröffentlichung fehlt
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Ergebnis der Prüfung
          content:
            application/json:
              schema:
                type: object
                properties:
                  ready: { type: boolean }
                  missing:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string }

  /inquiries:
    get:
      tags: [Anfragen]
      summary: Eigene Anfragen auflisten
      description: |
        Anfragen des angemeldeten Kontos — über alle Vermieter hinweg. Enthält
        keine Bewerberdaten; die gehören ins Vermieterportal.
      responses:
        "200": { $ref: "#/components/responses/Anfrageliste" }
        "401": { $ref: "#/components/responses/NichtAngemeldet" }

  /inquiries/{inquiry}:
    parameters:
      - { name: inquiry, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Anfragen]
      summary: Eigene Anfrage abrufen
      responses:
        "200": { $ref: "#/components/responses/Anfrage" }
        "404": { description: Nicht gefunden oder nicht die eigene Anfrage }
    delete:
      tags: [Anfragen]
      summary: Anfrage zurückziehen
      description: Soft Delete — der Vorgang bleibt für die Nachvollziehbarkeit erhalten.
      responses:
        "204": { description: Zurückgezogen }
        "404": { description: Nicht gefunden oder nicht die eigene Anfrage }

  /properties/{property}/inquiries:
    post:
      tags: [Anfragen]
      summary: Anfrage zu einem Objekt stellen
      description: |
        Aus der Vermarktungsart des Objekts ergibt sich die Art der Anfrage:
        Miete führt zur Mietbewerbung, Kauf zur Kaufanfrage.

        Abgewiesen wird, wer bereits angefragt hat oder wem das Objekt selbst
        gehört. Der Mandant wird serverseitig aus dem Objekt übernommen.

        Wer noch keine Bewerbungsmappe hat, bekommt dabei eine angelegt.
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                free_text:
                  type: string
                  maxLength: 5000
                  description: Anschreiben; wird ab Phase 2 zusätzlich KI-ausgewertet
                desired_move_in:
                  type: string
                  format: date
                  description: darf nicht in der Vergangenheit liegen
      responses:
        "201": { $ref: "#/components/responses/Anfrage" }
        "401": { $ref: "#/components/responses/NichtAngemeldet" }
        "404": { description: Objekt nicht gefunden oder nicht veröffentlicht }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /portal/inquiries:
    get:
      tags: [Anfragen]
      summary: Anfragen des eigenen Mandanten
      description: Standardmäßig nach Trefferpunktzahl sortiert, unbewertete zuletzt.
      parameters:
        - { name: property_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, example: neu } }
        - { name: per_page, in: query, schema: { type: integer, maximum: 100, default: 25 } }
      responses:
        "200": { $ref: "#/components/responses/Anfrageliste" }
        "403": { $ref: "#/components/responses/KeineBerechtigung" }

  /portal/properties/{property}/inquiries:
    get:
      tags: [Anfragen]
      summary: Anfragen zu einem eigenen Objekt
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Anfrageliste" }
        "403": { $ref: "#/components/responses/KeineBerechtigung" }

  /integration/properties:
    get:
      tags: [Maschinenzugang]
      summary: Objekte des Mandanten
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: status, in: query, schema: { type: string } }
        - { name: geaendert_seit, in: query, schema: { type: string, format: date-time },
            description: Nur Objekte, die danach geändert wurden — für abgleichende Systeme }
        - { name: per_page, in: query, schema: { type: integer, maximum: 200, default: 50 } }
      responses:
        "200": { $ref: "#/components/responses/Objektliste" }
        "401": { description: Kein oder ungültiger Schlüssel }
        "403": { description: Dem Schlüssel fehlt `properties:read` }
    post:
      tags: [Maschinenzugang]
      summary: Objekt anlegen
      description: Der Mandant ergibt sich aus dem Schlüssel und wird nie vom Client übernommen.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyEingabe" }
      responses:
        "201": { $ref: "#/components/responses/Objekt" }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /integration/properties/{property}:
    parameters:
      - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Maschinenzugang]
      summary: Einzelnes Objekt
      security:
        - ApiKeyAuth: []
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "404": { description: Nicht gefunden oder anderer Mandant }
    patch:
      tags: [Maschinenzugang]
      summary: Objekt ändern
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyEingabe" }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
    delete:
      tags: [Maschinenzugang]
      summary: Objekt löschen
      security:
        - ApiKeyAuth: []
      responses:
        "204": { description: Gelöscht }

  /integration/properties/{property}/publish:
    post:
      tags: [Maschinenzugang]
      summary: Objekt veröffentlichen
      description: |
        Dieselbe Prüfung wie im Portal — auch über die Schnittstelle entstehen
        keine Inserate ohne die nach GEG § 87 vorgeschriebenen Angaben.
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/Objekt" }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /integration/properties/{property}/readiness:
    get:
      tags: [Maschinenzugang]
      summary: Prüfen, was zur Veröffentlichung fehlt
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: property, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Ergebnis
          content:
            application/json:
              schema:
                type: object
                properties:
                  ready: { type: boolean }
                  missing:
                    type: object
                    additionalProperties: { type: array, items: { type: string } }

  /integration/inquiries:
    get:
      tags: [Maschinenzugang]
      summary: Anfragen abholen
      description: |
        Nur lesend. Statuswechsel laufen über das Portal, weil sie den
        Feedback-Loop des Matchings speisen und eine handelnde Person festhalten.
      security:
        - ApiKeyAuth: []
      parameters:
        - { name: property_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string } }
        - { name: seit, in: query, schema: { type: string, format: date-time } }
      responses:
        "200": { $ref: "#/components/responses/Anfrageliste" }
        "403": { description: Dem Schlüssel fehlt `inquiries:read` }

  /auth/forgot-password:
    post:
      tags: [Konto]
      summary: Link zum Zurücksetzen des Passworts anfordern
      description: |
        Antwortet **immer gleich**, unabhängig davon, ob es zu der Adresse ein
        Konto gibt. Ein Unterschied wäre ein bequemes Werkzeug, um Konten
        aufzuspüren.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Antwort — sagt nichts über die Existenz des Kontos aus
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "429": { description: Zu viele Versuche }

  /auth/reset-password:
    post:
      tags: [Konto]
      summary: Passwort mit Token zurücksetzen
      description: |
        Der Token stammt aus der Mail. Nach dem Zurücksetzen werden **alle**
        Zugangstoken des Kontos verworfen — wer zurücksetzt, tut das oft, weil
        jemand anderes Zugang hatte.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, email, password, password_confirmation]
              properties:
                token: { type: string }
                email: { type: string, format: email }
                password: { type: string, format: password }
                password_confirmation: { type: string, format: password }
      responses:
        "200":
          description: Passwort geändert
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "422": { $ref: "#/components/responses/Validierungsfehler" }

  /auth/email/resend:
    post:
      tags: [Konto]
      summary: Bestätigungsmail erneut anfordern
      description: |
        Solange die Adresse unbestätigt ist, lassen sich keine Anfragen stellen
        und keine Objekte veröffentlichen. Suchen und die Mappe ausfüllen geht
        schon vorher.
      security:
        - BearerAuth: []
      responses:
        "200":
          description: Verschickt oder bereits bestätigt
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { description: Nicht angemeldet }
        "429": { description: Zu viele Versuche }

components:

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: Token aus `POST /auth/login`
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Schlüssel aus dem Portal unter „Schnittstellen". Er authentifiziert
        einen Mandanten, keine Person. Berechtigungen: `properties:read`,
        `properties:write`, `inquiries:read` — eine leere Liste bedeutet
        Vollzugriff.

  responses:
    Objekt:
      description: Ein Objekt
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { $ref: "#/components/schemas/Property" }
    Objektliste:
      description: Seitenweise Liste von Objekten
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: array
                items: { $ref: "#/components/schemas/Property" }
              meta: { $ref: "#/components/schemas/Seitenangaben" }
    Validierungsfehler:
      description: Eingabe fehlerhaft
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Validierungsfehler" }
    NichtAngemeldet:
      description: Token fehlt oder ist ungültig
    KeineBerechtigung:
      description: Für diese Aktion fehlt die Berechtigung
    Anfrage:
      description: Eine Anfrage
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { $ref: "#/components/schemas/Inquiry" }
    Anfrageliste:
      description: Seitenweise Liste von Anfragen
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: array
                items: { $ref: "#/components/schemas/Inquiry" }
              meta: { $ref: "#/components/schemas/Seitenangaben" }

  schemas:

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string, nullable: true }
        role:
          type: string
          enum: [vermieter, verwalter_mitarbeiter, interessent, admin]
        entity_type: { type: string, enum: [privatperson, firma] }
        email_verified: { type: boolean }
        tenant:
          allOf: [{ $ref: "#/components/schemas/Tenant" }]
          nullable: true
          description: null bei Interessenten — sie gehören zu keinem Mandanten

    Tenant:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [privat, verwaltung] }
        company_name: { type: string, nullable: true }

    PropertyType:
      type: object
      properties:
        id: { type: string, format: uuid }
        key: { type: string, example: wohnung }
        label: { type: string, example: Wohnung }
        criteria_schema:
          type: array
          description: Zulässige Ausstattungsmerkmale dieser Objektart
          items:
            type: object
            properties:
              key: { type: string, example: pets_allowed }
              type: { type: string, enum: [bool, int, decimal, select, multiselect] }
              label: { type: string, example: Haustiere erlaubt }

    ListingType:
      type: object
      properties:
        id: { type: string, format: uuid }
        key: { type: string, enum: [miete, kauf] }
        label: { type: string }

    Property:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        description: { type: string, nullable: true }
        property_type: { $ref: "#/components/schemas/PropertyType" }
        listing_type: { $ref: "#/components/schemas/ListingType" }
        address:
          type: object
          description: |
            `street`, `house_number` und die Koordinaten erscheinen nur bei
            freigegebener Adresse oder im Portal.
          properties:
            postal_code: { type: string }
            city: { type: string }
            district: { type: string, nullable: true }
            country: { type: string }
            display: { type: string, description: Anzeigefertige Adresse }
            is_public: { type: boolean }
            street: { type: string }
            house_number: { type: string }
            latitude: { type: number, nullable: true }
            longitude: { type: number, nullable: true }
        price:
          type: object
          properties:
            amount: { type: string, description: Kaltmiete oder Kaufpreis }
            service_charge: { type: string, nullable: true }
            heating_cost: { type: string, nullable: true }
            deposit: { type: string, nullable: true }
            commission_percent: { type: string, nullable: true }
        facts:
          type: object
          properties:
            rooms: { type: string, nullable: true, description: Halbe Zimmer sind zulässig }
            living_area: { type: string, nullable: true }
            plot_area: { type: string, nullable: true }
            floor: { type: integer, nullable: true }
            total_floors: { type: integer, nullable: true }
            construction_year: { type: integer, nullable: true }
            available_from: { type: string, format: date, nullable: true }
        hard_criteria:
          type: object
          additionalProperties: true
          description: Merkmale gemäß `criteria_schema` der Objektart
        energy_certificate:
          $ref: "#/components/schemas/Energieausweis"
        status:
          type: string
          enum: [entwurf, veroeffentlicht, reserviert, vermarktet, archiviert]
        published_at: { type: string, format: date-time, nullable: true }
        internal_reference:
          type: string
          nullable: true
          description: Nur im Portal enthalten
        inquiries_count:
          type: integer
          description: Nur im Portal enthalten

    Energieausweis:
      type: object
      nullable: true
      description: Pflichtangabe in Anzeigen nach GEG § 87
      properties:
        type: { type: string, enum: [verbrauch, bedarf] }
        value: { type: number, description: Kennwert in kWh/m²·a }
        energy_source: { type: string, example: gas }
        efficiency_class: { type: string, enum: ["A+", A, B, C, D, E, F, G, H] }
        issued_at: { type: string, format: date }
        exempt_reason:
          type: string
          description: Begründete Ausnahme statt Kennwerten, etwa Baudenkmal

    PropertyEingabe:
      type: object
      required: [property_type_id, listing_type_id, title, postal_code, city, price]
      properties:
        property_type_id: { type: string, format: uuid }
        listing_type_id: { type: string, format: uuid }
        title: { type: string, maxLength: 255 }
        description: { type: string, nullable: true }
        internal_reference: { type: string, nullable: true }
        street: { type: string, nullable: true }
        house_number: { type: string, nullable: true }
        postal_code: { type: string, pattern: '^\d{5}$' }
        city: { type: string }
        district: { type: string, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        address_public: { type: boolean, default: false }
        price: { type: number }
        service_charge: { type: number, nullable: true }
        deposit: { type: number, nullable: true }
        rooms: { type: number, nullable: true }
        living_area: { type: number, nullable: true }
        plot_area: { type: number, nullable: true }
        floor: { type: integer, nullable: true }
        construction_year: { type: integer, nullable: true }
        available_from: { type: string, format: date, nullable: true }
        hard_criteria:
          type: object
          additionalProperties: true
        energy_certificate:
          $ref: "#/components/schemas/Energieausweis"

    Inquiry:
      type: object
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [mietbewerbung, kaufanfrage]
          description: ergibt sich aus der Vermarktungsart des Objekts
        free_text: { type: string, nullable: true }
        desired_move_in: { type: string, format: date, nullable: true }
        matching_score:
          type: integer
          nullable: true
          minimum: 0
          maximum: 100
          description: von der Matching-Engine gesetzt, null solange unbewertet
        status:
          type: object
          properties:
            key: { type: string, example: neu }
            label: { type: string, example: Neu }
            is_final: { type: boolean }
        property: { $ref: "#/components/schemas/Property" }
        applicant:
          type: object
          nullable: true
          description: |
            Nur im Vermieterportal. Enthält ausschließlich grobe Signale —
            SCHUFA-, Einkommens- und Finanzierungsdaten liegen verschlüsselt vor
            und sind nur über eine ausdrückliche Dokumentfreigabe einsehbar.
          properties:
            id: { type: string, format: uuid }
            name: { type: string, nullable: true }
            income_bracket:
              type: string
              nullable: true
              enum: [unter_1500, 1500_2500, 2500_4000, 4000_6000, ueber_6000]
            schufa_band:
              type: string
              nullable: true
              enum: [sehr_gut, gut, mittel, schwach]
            has_income_proof: { type: boolean }
            has_schufa: { type: boolean }
            has_financing_confirmation: { type: boolean }
            completeness_percent: { type: integer }
        created_at: { type: string, format: date-time }

    Seitenangaben:
      type: object
      properties:
        current_page: { type: integer }
        last_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }

    Validierungsfehler:
      type: object
      properties:
        message: { type: string }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
