openapi: 3.1.0
info:
  title: HeyHuman API
  version: 1.1.0-pilot
  description: Fast, structured human judgment for customer-facing visual work.
servers:
  - url: https://heyhuman.vercel.app/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local development
security:
  - bearerAuth: []
paths:
  /assets:
    post:
      operationId: createAssetUpload
      summary: Create a private, single-use upload session
      description: Upload the bytes with the returned Supabase signed-upload token, then finalize the asset before using its reference in a review.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAsset" }
      responses:
        "201":
          description: Private upload session created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Asset" }
        default: { $ref: "#/components/responses/Error" }
  /assets/{assetId}:
    get:
      operationId: getAsset
      summary: Retrieve asset validation metadata for the authenticated organization
      parameters:
        - $ref: "#/components/parameters/AssetId"
      responses:
        "200":
          description: Asset metadata
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Asset" }
        default: { $ref: "#/components/responses/Error" }
  /assets/import:
    post:
      operationId: importRemoteAsset
      summary: Import a remote image into private HeyHuman storage
      description: Fetches a public or presigned HTTPS URL with SSRF protection, validates the real image bytes, and creates a normalized reviewer-safe WebP copy.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ImportAsset" }
      responses:
        "201":
          description: Remote image safely imported
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Asset" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: finalizeAsset
      summary: Validate uploaded image bytes and make the asset usable
      parameters:
        - $ref: "#/components/parameters/AssetId"
      responses:
        "200":
          description: Finalized asset metadata
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/Asset" }
        default: { $ref: "#/components/responses/Error" }
  /reviews:
    get:
      operationId: listReviews
      summary: List review requests for the authenticated organization
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - in: query
          name: status
          schema: { $ref: "#/components/schemas/ReviewStatus" }
      responses:
        "200":
          description: Review requests
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ReviewRequest" }
        default: { $ref: "#/components/responses/Error" }
    post:
      operationId: createReview
      summary: Create or replay a review request
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateReviewRequest" }
      responses:
        "201":
          description: Review created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewRequest" }
        "200":
          description: Idempotent replay of the original review
          headers:
            Idempotent-Replayed:
              schema: { type: string, const: "true" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewRequest" }
        default: { $ref: "#/components/responses/Error" }
  /reviews/{reviewId}:
    get:
      operationId: getReview
      summary: Retrieve the canonical review state and result
      parameters:
        - $ref: "#/components/parameters/ReviewId"
      responses:
        "200":
          description: Review request
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewRequest" }
        default: { $ref: "#/components/responses/Error" }
  /reviews/{reviewId}/cancel:
    post:
      operationId: cancelReview
      summary: Cancel a nonterminal review request
      parameters:
        - $ref: "#/components/parameters/ReviewId"
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Cancelled review request
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewRequest" }
        default: { $ref: "#/components/responses/Error" }
  /reviews/{reviewId}/outcome:
    post:
      operationId: reportReviewOutcome
      summary: Report what the customer did after receiving a completed result
      parameters:
        - $ref: "#/components/parameters/ReviewId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateOutcome" }
      responses:
        "201":
          description: Outcome recorded
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewOutcome" }
        "200":
          description: Outcome updated or idempotently replayed
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data: { $ref: "#/components/schemas/ReviewOutcome" }
        default: { $ref: "#/components/responses/Error" }
  /reviewer/me:
    get:
      operationId: getReviewerProfile
      summary: Retrieve the authenticated reviewer's profile, availability, and earnings ledger
      security: [{ reviewerBearerAuth: [] }]
      responses:
        "200": { description: Reviewer profile and earnings }
        default: { $ref: "#/components/responses/Error" }
    patch:
      operationId: updateReviewerAvailability
      summary: Pause or resume task matching
      security: [{ reviewerBearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [available]
              properties:
                available: { type: boolean }
      responses:
        "200": { description: Availability updated }
        default: { $ref: "#/components/responses/Error" }
  /reviewer/tasks:
    get:
      operationId: listReviewerTasks
      summary: List redacted task offers plus the reviewer's own active and submitted tasks
      security: [{ reviewerBearerAuth: [] }]
      responses:
        "200":
          description: Reviewer task list
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/SuccessEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/ReviewerTask" }
        default: { $ref: "#/components/responses/Error" }
  /reviewer/tasks/{reviewId}:
    get:
      operationId: getReviewerTask
      summary: Retrieve a redacted offer or the full signed brief after claim
      security: [{ reviewerBearerAuth: [] }]
      parameters: [{ $ref: "#/components/parameters/ReviewId" }]
      responses:
        "200": { description: Reviewer task }
        default: { $ref: "#/components/responses/Error" }
  /reviewer/tasks/{reviewId}/claim:
    post:
      operationId: claimReviewerTask
      summary: Atomically claim a task for ten minutes
      security: [{ reviewerBearerAuth: [] }]
      parameters: [{ $ref: "#/components/parameters/ReviewId" }]
      responses:
        "200": { description: Claimed task with signed asset access }
        default: { $ref: "#/components/responses/Error" }
  /reviewer/tasks/{reviewId}/submissions:
    post:
      operationId: submitReviewerDecision
      summary: Submit an independent structured judgment and create pending earnings
      security: [{ reviewerBearerAuth: [] }]
      parameters: [{ $ref: "#/components/parameters/ReviewId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewerSubmission" }
      responses:
        "201": { description: Decision accepted and earnings recorded }
        default: { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: HeyHuman API key
    reviewerBearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Supabase access token for an invited reviewer
  parameters:
    AssetId:
      in: path
      name: assetId
      required: true
      schema: { type: string, pattern: "^ast_[a-f0-9]{32}$" }
    ReviewId:
      in: path
      name: reviewId
      required: true
      schema: { type: string, pattern: "^rev_[a-f0-9]{20}$" }
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      description: Stable key for safely retrying this exact write body.
      schema: { type: string, minLength: 8, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" }
  responses:
    Error:
      description: Machine-readable error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
  schemas:
    CreateAsset:
      type: object
      additionalProperties: false
      required: [filename, content_type, byte_size]
      properties:
        filename: { type: string, minLength: 1, maxLength: 255 }
        content_type: { type: string, enum: [image/png, image/jpeg, image/webp, image/avif, image/gif] }
        byte_size: { type: integer, minimum: 1, maximum: 26214400 }
    ImportAsset:
      type: object
      additionalProperties: false
      required: [url]
      properties:
        url: { type: string, format: uri, maxLength: 4096 }
        filename: { type: string, minLength: 1, maxLength: 255 }
    Asset:
      type: object
      required: [id, object, status, reference, filename, content_type, byte_size, retention_expires_at]
      properties:
        id: { type: string }
        object: { type: string, const: asset }
        status: { type: string, enum: [awaiting_upload, ready, rejected, deleted] }
        reference: { type: string, pattern: "^heyhuman-asset://ast_[a-f0-9]{32}$" }
        filename: { type: string }
        content_type: { type: string }
        byte_size: { type: integer }
        width: { type: [integer, "null"] }
        height: { type: [integer, "null"] }
        checksum_sha256: { type: [string, "null"] }
        retention_expires_at: { type: string, format: date-time }
        upload:
          type: [object, "null"]
          description: Returned only when creating an upload session. The signed URL expires after two hours.
    ReviewStatus:
      type: string
      enum: [validating, queued, triaged, awaiting_coverage, in_review, aggregating, needs_adjudication, completed, rejected_out_of_scope, asset_failed, expired, cancelled]
    ReviewType:
      type: string
      enum: [publish_readiness, visible_problems, compare]
    RequestedCheck:
      type: string
      enum: [visible_completeness, readability, visual_hierarchy, credibility, brand_fit, primary_action_clarity]
    CreateReviewRequest:
      type: object
      additionalProperties: false
      required: [review_type, asset_url, output_type, audience, market, language, intent, requested_checks, customer_reference]
      properties:
        review_type: { $ref: "#/components/schemas/ReviewType" }
        output_type: { type: string, enum: [image, webpage_screenshot] }
        asset_url:
          oneOf:
            - { type: string, format: uri, maxLength: 2048 }
            - { type: string, pattern: "^heyhuman-asset://ast_[a-f0-9]{32}$" }
        asset_name: { type: string, maxLength: 255 }
        comparison_asset_url:
          oneOf:
            - { type: string, format: uri, maxLength: 2048 }
            - { type: string, pattern: "^heyhuman-asset://ast_[a-f0-9]{32}$" }
          description: Required for compare reviews and rejected for other review types.
        comparison_asset_name: { type: string, maxLength: 255 }
        audience: { type: string, minLength: 1, maxLength: 500 }
        market: { type: string, minLength: 2, maxLength: 100 }
        language: { type: string, minLength: 2, maxLength: 35 }
        intent: { type: string, minLength: 1, maxLength: 500 }
        requested_checks:
          type: array
          minItems: 1
          maxItems: 6
          uniqueItems: true
          items: { $ref: "#/components/schemas/RequestedCheck" }
        customer_reference: { type: string, minLength: 1, maxLength: 128 }
        notes: { type: string, maxLength: 1000 }
        coverage_policy: { type: string, const: two_reviewers, default: two_reviewers }
    ReviewRequest:
      type: object
      required: [id, object, review_type, status, customer_reference, assets, brief, coverage, quote, estimated_completion_at, result, created_at, updated_at]
      properties:
        id: { type: string }
        object: { type: string, const: review_request }
        review_type: { $ref: "#/components/schemas/ReviewType" }
        status: { $ref: "#/components/schemas/ReviewStatus" }
        customer_reference: { type: string }
        assets:
          type: array
          minItems: 1
          maxItems: 2
          items:
            type: object
            required: [url, name, option]
            properties:
              url: { type: string, format: uri }
              name: { type: [string, "null"] }
              option: { type: [string, "null"], enum: [a, b, null] }
        brief:
          type: object
          required: [output_type, audience, market, language, intended_action, requested_checks, context_note]
          properties:
            output_type: { type: string, enum: [image, webpage_screenshot] }
            audience: { type: string }
            market: { type: string }
            language: { type: string }
            intended_action: { type: string }
            requested_checks:
              type: array
              items: { $ref: "#/components/schemas/RequestedCheck" }
            context_note: { type: [string, "null"] }
        coverage:
          type: object
          required: [policy, required, completed]
          properties:
            policy: { type: string, const: two_reviewers }
            required: { type: integer, minimum: 1 }
            completed: { type: integer, minimum: 0 }
        quote:
          type: object
          required: [amount, currency]
          properties:
            amount: { type: integer, description: Price in the smallest currency unit. }
            currency: { type: string, pattern: "^[A-Z]{3}$" }
        estimated_completion_at: { type: string, format: date-time }
        result:
          oneOf:
            - type: "null"
            - $ref: "#/components/schemas/ReviewResult"
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ReviewResult:
      type: object
      required: [result_schema_version, decision, confidence, summary, issues, recommended_next_action, agreement, coverage, completed_at]
      properties:
        result_schema_version: { type: string }
        decision: { type: string }
        confidence: { type: [string, "null"] }
        summary: { type: [string, "null"] }
        issues:
          type: array
          items:
            type: object
            required: [code, count]
            properties:
              code: { type: string }
              count: { type: integer, minimum: 1 }
        recommended_next_action: { type: [string, "null"] }
        agreement:
          type: object
          properties:
            level: { type: [string, "null"] }
            percentage: { type: [integer, "null"], minimum: 0, maximum: 100 }
        coverage:
          type: object
          properties:
            required: { type: integer }
            completed: { type: integer }
        completed_at: { type: [string, "null"], format: date-time }
    ReviewerTask:
      type: object
      required: [id, access, review_type, output_type, created_at, pay, coverage, assignment, submission]
      properties:
        id: { type: string }
        access: { type: string, enum: [preview, claimed, submitted] }
        review_type: { $ref: "#/components/schemas/ReviewType" }
        output_type: { type: string, enum: [image, webpage_screenshot] }
        created_at: { type: string, format: date-time }
        assets:
          type: array
          description: Present only after this reviewer claims the task. URLs are short-lived.
          items:
            type: object
            properties:
              option: { type: [string, "null"], enum: [a, b, null] }
              url: { type: string, format: uri }
              name: { type: [string, "null"] }
        brief:
          type: object
          description: Present only after this reviewer claims the task.
    ReviewerSubmission:
      type: object
      additionalProperties: false
      required: [decision, confidence, reason, issue_codes]
      properties:
        decision: { type: string, enum: [ready_to_publish, needs_revision, no_visible_problems, visible_problems_found, option_a, option_b, tie, cannot_assess] }
        confidence: { type: string, enum: [low, medium, high] }
        reason: { type: string, minLength: 10, maxLength: 1000 }
        issue_codes:
          type: array
          uniqueItems: true
          items: { type: string, enum: [cropping_or_clipping, text_readability, layout_or_hierarchy, trust_or_credibility, brand_mismatch, primary_action_unclear, broken_or_missing_element, other] }
    CreateOutcome:
      type: object
      additionalProperties: false
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [published, revised, rejected, ignored, prevented_from_publishing]
        note: { type: string, maxLength: 1000 }
    ReviewOutcome:
      type: object
      required: [id, object, review_request_id, outcome, note, created_at, updated_at]
      properties:
        id: { type: string }
        object: { type: string, const: review_outcome }
        review_request_id: { type: string }
        outcome: { type: string }
        note: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    SuccessEnvelope:
      type: object
      required: [data, meta]
      properties:
        data: {}
        meta:
          type: object
          required: [request_id]
          properties:
            request_id: { type: string }
    ErrorEnvelope:
      type: object
      required: [error, meta]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            details: { type: array, items: { type: object } }
        meta:
          type: object
          required: [request_id]
          properties:
            request_id: { type: string }
