openapi: 3.1.0
info:
  title: BuildSignal API
  version: 1.0.0
  description: Explainable Irish construction-project opportunity intelligence. Inferred fields are explicitly labelled.
servers:
  - url: /api/v1
tags:
  - name: Catalogue
  - name: Opportunities
  - name: Identity
  - name: User
  - name: Admin
paths:
  /health:
    get:
      tags: [Catalogue]
      operationId: health
      responses:
        '200': {description: Service and database are available, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /trades:
    get:
      tags: [Catalogue]
      operationId: listTrades
      responses:
        '200': {description: Active trade taxonomy, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /places/search:
    get:
      tags: [Catalogue]
      operationId: searchPlaces
      parameters:
        - {in: query, name: q, required: true, schema: {type: string, minLength: 2}}
      responses:
        '200': {description: Irish place-name matches, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /session:
    get:
      tags: [Identity]
      operationId: session
      responses:
        '200': {description: Anonymous/authenticated state and CSRF token, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /session/search-profile:
    put:
      tags: [Identity]
      operationId: saveAnonymousSearch
      security: [{cookieSession: [], csrfToken: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/SearchProfileInput'}
      responses:
        '200': {description: Saved, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '422': {$ref: '#/components/responses/Error'}
  /session/projects/{id}/viewed:
    post:
      tags: [Identity]
      operationId: recordAnonymousProjectView
      security: [{cookieSession: [], csrfToken: []}]
      parameters: [{$ref: '#/components/parameters/ProjectId'}]
      responses:
        '204': {description: View remembered for anonymous-state migration}
        '404': {$ref: '#/components/responses/Error'}
  /opportunities:
    get:
      tags: [Opportunities]
      operationId: searchOpportunities
      parameters:
        - {in: query, name: trade, required: true, schema: {type: string, example: electrician}}
        - {in: query, name: place_id, schema: {type: string, format: uuid}}
        - {in: query, name: lat, schema: {type: number, format: double}}
        - {in: query, name: lng, schema: {type: number, format: double}}
        - {in: query, name: radius_km, required: true, schema: {type: number, minimum: 1, maximum: 200, example: 40}}
        - {in: query, name: cursor, schema: {type: string}}
        - {in: query, name: limit, schema: {type: integer, minimum: 1, maximum: 50, default: 20}}
      responses:
        '200':
          description: Ranked, preprocessed opportunities. A place_id or latitude/longitude pair is required.
          headers:
            RateLimit-Limit: {schema: {type: integer}}
            RateLimit-Remaining: {schema: {type: integer}}
            RateLimit-Reset: {schema: {type: integer}}
          content:
            application/json:
              schema:
                allOf:
                  - {$ref: '#/components/schemas/Envelope'}
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          opportunities: {type: array, items: {$ref: '#/components/schemas/Opportunity'}}
        '422': {$ref: '#/components/responses/Error'}
        '429': {$ref: '#/components/responses/Error'}
  /projects/{id}:
    get:
      tags: [Opportunities]
      operationId: projectDetail
      parameters:
        - {$ref: '#/components/parameters/ProjectId'}
        - {in: query, name: trade, schema: {type: string}}
      responses:
        '200': {description: Canonical project, source facts, and separately labelled BuildSignal estimates, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '404': {$ref: '#/components/responses/Error'}
  /projects/{id}/timeline:
    get:
      tags: [Opportunities]
      operationId: projectTimeline
      parameters: [{$ref: '#/components/parameters/ProjectId'}]
      responses:
        '200': {description: Sourced and inferred lifecycle events with provenance, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '404': {$ref: '#/components/responses/Error'}
  /auth/providers:
    get:
      tags: [Identity]
      operationId: identityProviders
      responses:
        '200': {description: Only correctly configured OIDC providers, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /me:
    get:
      tags: [User]
      operationId: currentUser
      security: [{cookieSession: []}]
      responses:
        '200': {description: Current user and search profiles, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '401': {$ref: '#/components/responses/Error'}
  /me/opportunities:
    get:
      tags: [User]
      operationId: personalisedOpportunities
      security: [{cookieSession: []}]
      parameters:
        - {in: query, name: trade, required: true, schema: {type: string}}
        - {in: query, name: place_id, schema: {type: string, format: uuid}}
        - {in: query, name: lat, schema: {type: number}}
        - {in: query, name: lng, schema: {type: number}}
        - {in: query, name: radius_km, required: true, schema: {type: number, minimum: 1, maximum: 200}}
      responses:
        '200': {description: Opportunity results adjusted only by this user's feedback, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '401': {$ref: '#/components/responses/Error'}
  /me/projects:
    get:
      tags: [User]
      operationId: userProjects
      security: [{cookieSession: []}]
      parameters:
        - {in: query, name: trade, required: true, schema: {type: string}}
        - {in: query, name: state, required: true, schema: {type: string, enum: [watched, interested, hidden]}}
      responses:
        '200': {description: Saved or hidden projects, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /me/projects/{id}/actions/{action}:
    parameters:
      - {$ref: '#/components/parameters/ProjectId'}
      - {in: path, name: action, required: true, schema: {type: string, enum: [interested, watch, not_for_me]}}
    put:
      tags: [User]
      operationId: setProjectAction
      security: [{cookieSession: [], csrfToken: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [trade]
              properties:
                trade: {type: string}
                reason_code: {type: string, enum: [wrong_type, too_far, too_small, too_large, wrong_timing, already_know, other]}
      responses:
        '200': {description: Current per-trade project state, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
    delete:
      tags: [User]
      operationId: removeProjectAction
      security: [{cookieSession: [], csrfToken: []}]
      requestBody:
        required: true
        content: {application/json: {schema: {type: object, required: [trade], properties: {trade: {type: string}}}}}
      responses:
        '200': {description: Current per-trade project state, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /admin/overview:
    get:
      tags: [Admin]
      operationId: adminOverview
      security: [{cookieSession: []}]
      responses:
        '200': {description: Imports, counts, and background job status, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '403': {$ref: '#/components/responses/Error'}
  /admin/projects/search:
    get:
      tags: [Admin]
      operationId: adminProjectSearch
      security: [{cookieSession: []}]
      parameters: [{in: query, name: q, required: true, schema: {type: string}}]
      responses:
        '200': {description: Project diagnostics search, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /admin/projects/{id}:
    get:
      tags: [Admin]
      operationId: adminProjectDetail
      security: [{cookieSession: []}]
      parameters: [{$ref: '#/components/parameters/ProjectId'}]
      responses:
        '200': {description: Source facts, classifications, lifecycle, trade scores, and ambiguous link candidates, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
        '404': {$ref: '#/components/responses/Error'}
  /admin/diagnostics:
    get:
      tags: [Admin]
      operationId: scoringDiagnostics
      security: [{cookieSession: []}]
      parameters:
        - {in: query, name: project, required: true, schema: {type: string, format: uuid}}
        - {in: query, name: trade, required: true, schema: {type: string}}
      responses:
        '200': {description: Full score factors, reasons, and source-link rationale, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /admin/feedback:
    get:
      tags: [Admin]
      operationId: aggregateFeedback
      security: [{cookieSession: []}]
      responses:
        '200': {description: Aggregated actions and rejection reasons, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
  /admin/jobs:
    post:
      tags: [Admin]
      operationId: enqueueAdminJob
      security: [{cookieSession: [], csrfToken: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type: {type: string, enum: [import_planning, import_building_control, import_places, rebuild_project_links, recalculate_projects, recalculate_opportunities]}
                payload: {type: object, additionalProperties: true}
      responses:
        '202': {description: Job queued for a CLI worker, content: {application/json: {schema: {$ref: '#/components/schemas/Envelope'}}}}
components:
  securitySchemes:
    cookieSession: {type: apiKey, in: cookie, name: buildsignal_session}
    csrfToken: {type: apiKey, in: header, name: X-CSRF-Token}
  parameters:
    ProjectId: {in: path, name: id, required: true, schema: {type: string, format: uuid}}
  responses:
    Error:
      description: Consistent API error
      content: {application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}}}
  schemas:
    Envelope:
      type: object
      required: [data, meta]
      properties:
        data: {type: object, additionalProperties: true}
        meta:
          type: object
          required: [request_id, api_version]
          properties:
            request_id: {type: string, format: uuid}
            api_version: {const: '1'}
            next_cursor: {type: [string, 'null']}
    ErrorEnvelope:
      type: object
      required: [error, meta]
      properties:
        error:
          type: object
          required: [code, message, details]
          properties:
            code: {type: string}
            message: {type: string}
            details: {type: object}
        meta: {type: object}
    SearchProfileInput:
      type: object
      required: [trade, radius_km]
      properties:
        trade: {type: string}
        place_id: {type: string, format: uuid}
        label: {type: string}
        lat: {type: number}
        lng: {type: number}
        radius_km: {type: number, minimum: 1, maximum: 200}
    ScoreReason:
      type: object
      required: [type, impact, text]
      properties:
        type: {type: string, enum: [trade, timing, scale, distance, personalisation]}
        impact: {type: string, enum: [positive, neutral, negative]}
        text: {type: string}
    Opportunity:
      type: object
      required: [id, name, distance_km, category, score, match_band, reasons]
      properties:
        id: {type: string, format: uuid}
        name: {type: string}
        description: {type: [string, 'null']}
        location: {type: string}
        latitude: {type: number}
        longitude: {type: number}
        distance_km: {type: number}
        category: {type: string}
        scale: {type: object}
        lifecycle_state: {type: string}
        estimated_stage: {type: [string, 'null'], description: 'BuildSignal inference, not an observed stage.'}
        estimated_stage_window: {type: object}
        opportunity_timing: {type: [object, 'null'], description: Estimated trade-specific opportunity window.}
        score: {type: integer, minimum: 0, maximum: 100}
        match_band: {type: string}
        reasons: {type: array, items: {$ref: '#/components/schemas/ScoreReason'}}
