openapi: 3.1.0
info:
  title: Alto Vale Shopping — Properties REST API
  version: "1.0.0"
  description: |
    REST API for external partners to synchronize real estate properties.

    Auth via API Key (header `X-API-Key`). Rate-limited per key.
    Source-of-truth: `docs/external-api.md` (human-readable companion).

    Notes:
    - Field allowlist (`WRITABLE_PROPERTY_FIELDS`) silently filters body
      writes — non-listed fields are dropped, not rejected.
    - `owner_id`, `id`, timestamps, `view_count`, `featured` are always
      server-controlled.
    - DELETE is **soft** (sets `status="removido"`).
  contact:
    email: contato@altovaleshopping.com
  license:
    name: Proprietary

servers:
  - url: https://altovaleshopping.com/api/v1
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Properties
    description: CRUD operations on the owner's property listings.

  - name: Images
    description: Property gallery management (add/remove photos).

  - name: Leads
    description: CRM leads generated by the marketplace. Read-only in v1.

  - name: Visits
    description: Visit bookings for the account's listings. Read-only in v1.

  - name: Waitlist
    description: Buyers waiting for matching listings. Read-only in v1.

  - name: Messages
    description: Chat conversations of the key owner. Read-only in v1.
paths:
  /properties:
    get:
      tags: [Properties]
      summary: List owner's properties
      description: |
        Returns properties belonging to the API key owner only.

        Each item carries `cover_image` (first photo by position, `null` when the
        gallery is empty) so a listing can be rendered without one extra request
        per property.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StatusFilter"
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          description: |
            Only properties whose `updated_at` is at or after this ISO timestamp.
            Use it for incremental sync instead of re-scanning the whole inventory.
      responses:
        "200":
          description: Paginated list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PropertyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Properties]
      summary: Create a property
      description: |
        Body fields outside `WRITABLE_PROPERTY_FIELDS` are silently dropped.
        `owner_id` is always derived from the API key — attempts to override
        are ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PropertyWriteInput"
      responses:
        "201":
          description: Property created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Property"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /properties/{id}:
    parameters:
      - $ref: "#/components/parameters/PropertyId"
    get:
      tags: [Properties]
      summary: Get a single property (with images)
      responses:
        "200":
          description: Property + images array
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Property"
                  - type: object
                    properties:
                      images:
                        type: array
                        items:
                          $ref: "#/components/schemas/PropertyImage"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    put:
      tags: [Properties]
      summary: Replace/update property
      description: Same sanitization behaviour as POST. Omitted fields are kept (PUT acts as PATCH).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PropertyWriteInput"
      responses:
        "200":
          description: Updated property
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Property"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      tags: [Properties]
      summary: Partial update (alias of PUT)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PropertyWriteInput"
      responses:
        "200":
          description: Updated property
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Property"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [Properties]
      summary: Soft delete (sets status=removido)
      responses:
        "200":
          description: Soft-deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  status:
                    type: string
                    enum: [removido]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /properties/{id}/images:
    parameters:
      - $ref: "#/components/parameters/PropertyId"
    post:
      tags: [Images]
      summary: Add photos to a property gallery
      description: |
        Send public image **URLs** — the server downloads each one and re-hosts
        it in the `property-images` bucket. There is no binary upload.

        With `replace: true` the swap never shrinks a live listing. New photos
        are inserted at negative positions while the current gallery keeps
        serving the property, and the swap is only confirmed when the new
        gallery ends up LARGER than the current one — otherwise everything
        uploaded is discarded and the gallery stays exactly as it was. This
        protects against a partial download: if the source CDN fails halfway
        through a 30-photo gallery and only 2 land, the listing keeps its 30.

        Responses: `201` with `replaced: true` when the gallery was replaced;
        `200` with `replaced: false` and a `reason` when it was kept; `400`
        when no URL could be fetched at all.

        A URL is skipped (counted in `skipped`, never failing the request) when
        the response is not 2xx, the content type is not an image, the file is
        under 5 KB, the image is below 500px on **both** axes, or it exceeds
        15 MB.

        Only public http/https hosts are accepted. localhost, loopback, private
        ranges, CGNAT, link-local (including the cloud metadata endpoint), IPv6
        literals and `.internal` domains are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_urls]
              properties:
                source_urls:
                  type: array
                  maxItems: 50
                  items:
                    type: string
                    format: uri
                  example:
                    - https://cdn.exemplo.com/foto1.jpg
                    - https://cdn.exemplo.com/foto2.jpg
                replace:
                  type: boolean
                  default: false
                  description: Replace the whole gallery instead of appending.
      responses:
        "201":
          description: Photos processed
          content:
            application/json:
              schema:
                type: object
                properties:
                  property_id: { type: string, format: uuid }
                  added: { type: integer, description: Photos actually stored }
                  skipped: { type: integer, description: URLs rejected by the filters }
                  total: { type: integer, description: Gallery size after the operation }
                  replaced: { type: boolean, description: Whether the gallery was actually swapped }
                  images:
                    type: array
                    items: { type: string, format: uri }
        "400":
          description: Invalid body, or `replace` requested and no image could be fetched
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /properties/{id}/images/{imageId}:
    parameters:
      - $ref: "#/components/parameters/PropertyId"
      - name: imageId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Images]
      summary: Remove one photo
      description: Deletes the `property_images` row and the file in the bucket.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  id: { type: string, format: uuid }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /leads:
    get:
      tags: [Leads]
      summary: List CRM leads
      description: |
        Leads received by the key owner's account (visits, sale proposals,
        waitlist, saved searches, contact forms, manual entries).

        Row scope is derived from the key, never from the request: an agency key
        sees the agency's leads; a linked broker's key sees only leads assigned
        to that broker.
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: origin
          in: query
          schema: { type: string }
          description: "Filter by origin (shopping, quer_vender, contato, externo, indicacao, visita, proposta_venda, lista_espera, busca_salva)"
        - name: column
          in: query
          schema: { type: string }
          description: "Filter by pipeline column id (e.g. col_novo, col_ganho, col_perdido)"
        - name: updated_since
          in: query
          schema: { type: string, format: date-time }
          description: Only leads updated at or after this ISO timestamp
      responses:
        "200":
          description: Paginated leads
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Lead" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /leads/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Leads]
      summary: Get a single lead
      description: Returns the lead plus `property_ids` — the listings linked to it in the CRM.
      responses:
        "200":
          description: Lead
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Lead"
                  - type: object
                    properties:
                      property_ids:
                        type: array
                        items: { type: string, format: uuid }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /leads/{id}/interactions:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Leads]
      summary: List a lead's interaction history
      description: Notes, calls, messages and pipeline moves recorded for the lead, newest first.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated interactions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/LeadInteraction" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /visits:
    get:
      tags: [Visits]
      summary: List visit bookings
      description: Visits requested for the account's listings. An agency key also sees its linked brokers' visits.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema: { type: string }
          description: Filter by booking status
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Visits on or after this date
        - name: to
          in: query
          schema: { type: string, format: date }
          description: Visits on or before this date
      responses:
        "200":
          description: Paginated visits
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Visit" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /visits/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Visits]
      summary: Get a single visit
      responses:
        "200":
          description: Visit
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Visit" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /waitlist:
    get:
      tags: [Waitlist]
      summary: List waitlist entries
      description: Buyers who asked to be notified about listings matching a profile (type, region, price range).
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: status
          in: query
          schema: { type: string }
      responses:
        "200":
          description: Paginated waitlist entries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WaitlistEntry" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /waitlist/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Waitlist]
      summary: Get a single waitlist entry
      responses:
        "200":
          description: Waitlist entry
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WaitlistEntry" }
        "404":
          $ref: "#/components/responses/NotFound"

  /messages:
    get:
      tags: [Messages]
      summary: List chat messages
      description: |
        Messages sent or received by the key owner. Unlike the other resources,
        this never includes a linked broker's conversations — those belong to
        the broker, not to the agency.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: property_id
          in: query
          schema: { type: string, format: uuid }
          description: Only messages about this listing
        - name: unread
          in: query
          schema: { type: boolean }
          description: "`true` returns only unread messages"
        - name: since
          in: query
          schema: { type: string, format: date-time }
          description: Only messages created at or after this ISO timestamp
      responses:
        "200":
          description: Paginated messages
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginationEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Message" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /messages/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      tags: [Messages]
      summary: Get a single message
      responses:
        "200":
          description: Message
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Format: `av_live_` + 32 hex chars. Generate at
        `/painel/configuracoes` → Integrações → Nova chave.

  parameters:
    Page:
      name: page
      in: query
      schema:
        type: integer
        default: 1
        minimum: 1
      description: 1-indexed page number
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        default: 20
        minimum: 1
        maximum: 100
      description: Items per page (max 100)
    StatusFilter:
      name: status
      in: query
      schema:
        type: string
        enum: [rascunho, ativo, pausado, vendido, removido]
      description: Filter by status
    PropertyId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid

    ResourceId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid

  responses:
    BadRequest:
      description: Invalid JSON body
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          example:
            error: "Invalid JSON body"
    Unauthorized:
      description: Missing, invalid or revoked API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          examples:
            missing:
              value:
                error: "Invalid or missing API key. Use header X-API-Key: av_live_..."
            invalid:
              value:
                error: "Invalid API key"
    Forbidden:
      description: Insufficient scope for the requested method
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          example:
            error: "Insufficient scope. Required: properties:write"
    NotFound:
      description: Property not found or not owned by the caller
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          example:
            error: "Property not found"
    RateLimited:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiError"
          example:
            error: "Rate limit exceeded. Max 60 req/min."

  schemas:
    ApiError:
      type: object
      required: [error]
      properties:
        error:
          type: string

    PaginationEnvelope:
      type: object
      required: [data, total, page, limit]
      properties:
        data:
          type: array
          items: { type: object }
        total: { type: integer, description: Total rows matching the filters }
        page: { type: integer }
        limit: { type: integer }

    Lead:
      type: object
      description: CRM lead. Read-only in API v1.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        message: { type: string, nullable: true }
        origin:
          type: string
          description: How the lead arrived
          enum: [shopping, quer_vender, contato, externo, indicacao, visita, proposta_venda, lista_espera, busca_salva]
        origin_label: { type: string, nullable: true }
        urgency: { type: string }
        status_column_id:
          type: string
          description: Pipeline column the lead currently sits in
        category: { type: string, nullable: true }
        source_property_id: { type: string, format: uuid, nullable: true }
        assigned_broker_id:
          type: string
          format: uuid
          nullable: true
          description: user_id of the broker responsible for the lead
        assigned_at: { type: string, format: date-time, nullable: true }
        is_external: { type: boolean }
        last_contact_at: { type: string, format: date-time, nullable: true }
        column_entered_at: { type: string, format: date-time, nullable: true }
        archived_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LeadInteraction:
      type: object
      properties:
        id: { type: string, format: uuid }
        lead_id: { type: string, format: uuid }
        type:
          type: string
          description: Interaction kind (note, call, message, pipeline move, …)
        content: { type: string, nullable: true }
        old_column_id: { type: string, nullable: true }
        new_column_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

    Visit:
      type: object
      description: Visit booking. Read-only in API v1.
      properties:
        id: { type: string, format: uuid }
        property_id: { type: string, format: uuid, nullable: true }
        visit_date: { type: string, format: date }
        time_slot: { type: string }
        status: { type: string }
        notes: { type: string, nullable: true }
        visitor_name: { type: string, nullable: true }
        visitor_phone: { type: string, nullable: true }
        assigned_broker_id: { type: string, format: uuid, nullable: true }
        owner_id: { type: string, format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    WaitlistEntry:
      type: object
      description: Buyer waiting for listings that match a profile. Read-only in API v1.
      properties:
        id: { type: string, format: uuid }
        property_type: { type: string, nullable: true }
        region: { type: string, nullable: true }
        price_min: { type: number, nullable: true }
        price_max: { type: number, nullable: true }
        message: { type: string, nullable: true }
        status: { type: string }
        created_at: { type: string, format: date-time }

    Message:
      type: object
      description: Chat message. Read-only in API v1.
      properties:
        id: { type: string, format: uuid }
        sender_id: { type: string, format: uuid }
        receiver_id: { type: string, format: uuid }
        property_id: { type: string, format: uuid, nullable: true }
        offer_id: { type: string, format: uuid, nullable: true }
        content: { type: string }
        read: { type: boolean }
        created_at: { type: string, format: date-time }

    PropertyListResponse:
      type: object
      required: [data, total, page, limit]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/PropertySummary"
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer

    PropertySummary:
      type: object
      description: Subset returned in list endpoints
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        slug: { type: string, nullable: true }
        property_type: { type: string, enum: [venda, aluguel, temporada] }
        category: { type: string }
        status: { type: string, enum: [rascunho, ativo, pausado, vendido, removido] }
        price: { type: number }
        rental_price: { type: number }
        area: { type: number, nullable: true }
        bedrooms: { type: integer, nullable: true }
        bathrooms: { type: integer, nullable: true }
        parking_spots: { type: integer, nullable: true }
        address_city: { type: string }
        address_neighborhood: { type: string }
        address_state: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        published_at: { type: string, format: date-time, nullable: true }
        view_count: { type: integer }
        reference_code: { type: string, nullable: true }
        cover_image:
          type: string
          format: uri
          nullable: true
          description: First gallery photo by position; `null` when there are no photos

    Property:
      type: object
      description: Full property record (returned by GET /:id and write endpoints)
      properties:
        id: { type: string, format: uuid, readOnly: true }
        owner_id: { type: string, format: uuid, readOnly: true }
        slug: { type: string, nullable: true }
        reference_code: { type: string, nullable: true, readOnly: true }
        title: { type: string }
        description: { type: string, nullable: true }
        property_type:
          type: string
          enum: [venda, aluguel, temporada]
        category:
          type: string
          enum: [apartamento, casa, terreno, comercial, rural, cobertura, kitnet, sobrado, galpao, sala_comercial]
        property_subtype: { type: string, nullable: true }
        property_stage:
          type: string
          nullable: true
          enum: [pronto, construcao, planta, reforma, null]
        status:
          type: string
          enum: [rascunho, ativo, pausado, vendido, removido]
        price: { type: number }
        rental_price: { type: number }
        last_price: { type: number, nullable: true }
        area: { type: number, nullable: true }
        total_area: { type: number, nullable: true }
        bedrooms: { type: integer, nullable: true }
        suites: { type: integer, nullable: true }
        bathrooms: { type: integer, nullable: true }
        parking_spots: { type: integer, nullable: true }
        living_rooms: { type: integer, nullable: true }
        address_street: { type: string }
        address_number: { type: string }
        address_complement: { type: string, nullable: true }
        address_neighborhood: { type: string }
        address_city: { type: string }
        address_state: { type: string }
        address_zip: { type: string, pattern: "^\\d{5}-?\\d{3}$" }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        condominium_fee: { type: number }
        iptu_monthly: { type: number }
        iptu_annual: { type: number }
        fire_insurance: { type: number }
        total_monthly_cost: { type: number, readOnly: true }
        price_per_sqm: { type: number, nullable: true, readOnly: true }
        accepts_financing: { type: boolean }
        accepts_exchange: { type: boolean }
        negotiable: { type: boolean }
        price_on_request: { type: boolean }
        furnishing:
          type: string
          enum: [sem_mobilia, semi_mobiliado, mobiliado]
        facing: { type: string, nullable: true }
        floor_number: { type: integer, nullable: true }
        total_floors: { type: integer, nullable: true }
        year_built: { type: integer, nullable: true }
        features:
          type: array
          items: { type: string }
        condominium_amenities:
          type: array
          items: { type: string }
        condominium_name: { type: string, nullable: true }
        has_electricity: { type: boolean }
        has_water: { type: boolean }
        has_sewage: { type: boolean }
        has_natural_gas: { type: boolean }
        has_paved_street: { type: boolean }
        rental_guarantee: { type: string, nullable: true }
        min_lease_months: { type: integer, nullable: true }
        available_from: { type: string, format: date, nullable: true }
        pets_allowed: { type: boolean }
        pets_details: { type: string, nullable: true }
        meta_title: { type: string, nullable: true }
        meta_description: { type: string, nullable: true }
        featured: { type: boolean, readOnly: true }
        highlight_until: { type: string, format: date-time, nullable: true, readOnly: true }
        view_count: { type: integer, readOnly: true }
        published_at: { type: string, format: date-time, nullable: true, readOnly: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time, readOnly: true }
        updated_at: { type: string, format: date-time, readOnly: true }

    PropertyWriteInput:
      type: object
      description: |
        Subset of `Property` accepting writes. Fields outside this allowlist
        in the request body are silently dropped — never trigger validation
        errors. The server-generated fields (`id`, `owner_id`, `created_at`,
        `view_count`, `featured`, etc.) cannot be set via this endpoint.
      properties:
        title: { type: string }
        description: { type: string }
        slug: { type: string }
        property_type:
          type: string
          enum: [venda, aluguel, temporada]
        category: { type: string }
        property_subtype: { type: string }
        property_stage: { type: string }
        status:
          type: string
          enum: [rascunho, ativo, pausado, vendido, removido]
        price: { type: number }
        rental_price: { type: number }
        last_price: { type: number }
        area: { type: number }
        total_area: { type: number }
        bedrooms: { type: integer }
        suites: { type: integer }
        bathrooms: { type: integer }
        parking_spots: { type: integer }
        living_rooms: { type: integer }
        address_street: { type: string }
        address_number: { type: string }
        address_complement: { type: string }
        address_neighborhood: { type: string }
        address_city: { type: string }
        address_state: { type: string }
        address_zip: { type: string }
        latitude: { type: number }
        longitude: { type: number }
        condominium_fee: { type: number }
        iptu_monthly: { type: number }
        iptu_annual: { type: number }
        fire_insurance: { type: number }
        accepts_financing: { type: boolean }
        accepts_exchange: { type: boolean }
        negotiable: { type: boolean }
        price_on_request: { type: boolean }
        furnishing:
          type: string
          enum: [sem_mobilia, semi_mobiliado, mobiliado]
        facing: { type: string }
        floor_number: { type: integer }
        total_floors: { type: integer }
        year_built: { type: integer }
        features:
          type: array
          items: { type: string }
        condominium_amenities:
          type: array
          items: { type: string }
        condominium_name: { type: string }
        has_electricity: { type: boolean }
        has_water: { type: boolean }
        has_sewage: { type: boolean }
        has_natural_gas: { type: boolean }
        has_paved_street: { type: boolean }
        rental_guarantee: { type: string }
        min_lease_months: { type: integer }
        available_from: { type: string, format: date }
        pets_allowed: { type: boolean }
        pets_details: { type: string }
        meta_title: { type: string }
        meta_description: { type: string }
        expires_at: { type: string, format: date-time }
      required:
        - title
        - property_type
        - category
        - address_street
        - address_number
        - address_neighborhood
        - address_city
        - address_state

    PropertyImage:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        position: { type: integer }
