openapi: 3.0.3
info:
  title: Town & Country Property Public API
  version: 1.0.0
  description: |
    Public, agent-friendly read API for properties and articles, plus a public enquiry submission endpoint.
    All endpoints are rate-limited and unauthenticated.
  contact:
    name: Town & Country Property
servers:
  - url: https://towncountryproperty.com
paths:
  /api/v1/public/properties/search:
    get:
      summary: Search properties
      description: Full-text search across the published property catalogue with structured filters.
      operationId: searchProperties
      parameters:
        - in: query
          name: q
          schema: { type: string, maxLength: 255 }
          description: Free-text query
        - in: query
          name: availability
          schema: { type: string, enum: [sale, rent, both] }
        - in: query
          name: type_id
          schema: { type: integer }
          description: Property type ID
        - in: query
          name: location_id
          schema: { type: integer }
        - in: query
          name: min_price
          schema: { type: number, minimum: 0 }
        - in: query
          name: max_price
          schema: { type: number, minimum: 0 }
        - in: query
          name: bedrooms
          schema: { type: string }
          description: e.g. "3" or "3+"
        - in: query
          name: bathrooms
          schema: { type: integer, minimum: 0 }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PropertySearchResponse'
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/public/properties/{id}:
    get:
      summary: Get a property by ID
      operationId: getProperty
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: '#/components/schemas/Property' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/public/articles/search:
    get:
      summary: Search articles / blog posts
      operationId: searchArticles
      parameters:
        - in: query
          name: q
          schema: { type: string, maxLength: 255 }
        - in: query
          name: category_id
          schema: { type: integer }
        - in: query
          name: tag
          schema: { type: string, maxLength: 100 }
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ArticleSearchResponse'
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/public/articles/{slug}:
    get:
      summary: Get an article by slug
      operationId: getArticle
      parameters:
        - in: path
          name: slug
          required: true
          schema: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { $ref: '#/components/schemas/Article' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /api/v1/public/options/property-types:
    get:
      summary: List property types
      description: Reference list of property types. Use the returned `id` for `type_id` in property search.
      operationId: listPropertyTypes
      responses:
        '200': { $ref: '#/components/responses/OptionList' }
  /api/v1/public/options/locations:
    get:
      summary: List active locations
      description: Reference list of locations. Use the returned `id` for `location_id` in property search.
      operationId: listLocations
      responses:
        '200': { $ref: '#/components/responses/OptionList' }
  /api/v1/public/options/features:
    get:
      summary: List public features
      description: Reference list of property features that are exposed to public search.
      operationId: listFeatures
      responses:
        '200': { $ref: '#/components/responses/OptionList' }
  /api/v1/public/options/blog-categories:
    get:
      summary: List blog categories
      operationId: listBlogCategories
      responses:
        '200': { $ref: '#/components/responses/OptionList' }
  /api/v1/public/options/blog-tags:
    get:
      summary: List blog tags
      operationId: listBlogTags
      responses:
        '200': { $ref: '#/components/responses/OptionList' }
  /api/v1/public/options/enums:
    get:
      summary: List enum filter values
      description: Reference list of enums (availability, bedrooms, bathrooms, etc.).
      operationId: listEnums
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    additionalProperties:
                      type: array
                      items: { type: string }
  /oauth/token:
    post:
      summary: Issue an access token (OAuth 2.0 password grant)
      description: |
        Exchange username + password for a Bearer token usable against `/mcp/private`.
        Form-encoded request, JSON response. Rate-limited 10/minute/IP.
      operationId: oauthToken
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, username, password]
              properties:
                grant_type: { type: string, enum: [password] }
                username: { type: string }
                password: { type: string }
                scope: { type: string, description: 'Space-separated scopes; supported: mcp.read mcp.write admin' }
      responses:
        '200':
          description: Token issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer }
                  scope: { type: string }
        '400':
          description: Invalid request
        '401':
          description: Invalid credentials
        '429':
          description: Rate limited
  /mcp:
    post:
      summary: Public MCP endpoint (JSON-RPC 2.0)
      description: |
        Public, unauthenticated MCP server. Methods: `initialize`, `tools/list`, `tools/call`, `ping`.
        Tools: search_properties, get_property, search_articles, get_article, submit_enquiry, list_options.
      operationId: publicMcp
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JsonRpcRequest' }
      responses:
        '200':
          description: JSON-RPC response
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
  /mcp/private:
    post:
      summary: Admin MCP endpoint (JSON-RPC 2.0, OAuth-protected)
      description: |
        Mirrors the admin AI assistant tool surface. Requires Bearer token from `/oauth/token`.
        Tool list is per-user. See `/.well-known/oauth-protected-resource` for auth metadata.
      operationId: adminMcp
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/JsonRpcRequest' }
      responses:
        '200':
          description: JSON-RPC response
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JsonRpcResponse' }
        '401':
          description: Missing or invalid bearer token
  /api/v1/public/enquiries:
    post:
      summary: Submit an enquiry
      description: Submit a public contact enquiry. Heavy rate limiting (5 per IP per hour).
      operationId: submitEnquiry
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnquiryInput'
      responses:
        '201':
          description: Enquiry created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      id: { type: integer }
                      status: { type: string }
                  message: { type: string }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
  schemas:
    JsonRpcRequest:
      type: object
      required: [jsonrpc, method]
      properties:
        jsonrpc: { type: string, enum: ["2.0"] }
        id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
        method: { type: string }
        params: { type: object }
    JsonRpcResponse:
      type: object
      properties:
        jsonrpc: { type: string, enum: ["2.0"] }
        id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
        result: { type: object }
        error:
          type: object
          properties:
            code: { type: integer }
            message: { type: string }
    Property:
      type: object
      properties:
        id: { type: integer }
        reference_id: { type: string, nullable: true }
        name: { type: string, nullable: true }
        slug: { type: string, nullable: true }
        url: { type: string, nullable: true }
        type: { type: string, nullable: true }
        location: { type: string, nullable: true }
        bedrooms: { type: integer, nullable: true }
        bathrooms: { type: integer, nullable: true }
        house_size: { type: number, nullable: true }
        land_size: { type: number, nullable: true }
        sale_price: { type: number, nullable: true }
        rent_price_monthly: { type: number, nullable: true }
        currency: { type: string }
        photo: { type: string, nullable: true }
    PropertySearchResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            results:
              type: array
              items: { $ref: '#/components/schemas/Property' }
            total: { type: integer }
            page: { type: integer }
            limit: { type: integer }
    Article:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        slug: { type: string }
        url: { type: string }
        excerpt: { type: string, nullable: true }
        category: { type: string, nullable: true }
        author: { type: string, nullable: true }
        published_at: { type: string, format: date-time, nullable: true }
        content: { type: string, nullable: true }
        tags:
          type: array
          items: { type: string }
    ArticleSearchResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            results:
              type: array
              items: { $ref: '#/components/schemas/Article' }
            total: { type: integer }
            page: { type: integer }
            limit: { type: integer }
    EnquiryInput:
      type: object
      required: [first_name, email, message]
      properties:
        first_name: { type: string, maxLength: 100 }
        last_name: { type: string, maxLength: 100, nullable: true }
        email: { type: string, format: email, maxLength: 255 }
        phone: { type: string, maxLength: 50, nullable: true }
        message: { type: string, maxLength: 5000 }
        inquiry_type: { type: string, enum: [general, property, development] }
        property_id: { type: integer, nullable: true }
        development_id: { type: integer, nullable: true }
        preferred_contact_method: { type: string, enum: [email, phone, line, whatsapp] }
        line_id: { type: string, maxLength: 100, nullable: true }
        whatsapp: { type: string, maxLength: 50, nullable: true }
        newsletter_subscribed: { type: boolean }
  responses:
    ValidationError:
      description: Validation failed
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              errors: { type: object, additionalProperties: { type: array, items: { type: string } } }
    RateLimited:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              message: { type: string }
              retry_after: { type: integer }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              message: { type: string }
    OptionList:
      description: Option list
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean }
              data:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    name: { type: string }
                    slug: { type: string, nullable: true }
              count: { type: integer }
