openapi: "3.1.0"
info:
  title: Bounty Board API
  version: "1.0"
  description: |
    Consulting work-management platform API (spec §6). All endpoints are
    session-authenticated (httpOnly cookie `bb_session`); mutations also
    require the CSRF double-submit header `X-CSRF-Token` mirroring the
    `bb_csrf` cookie. Errors use the envelope
    `{"error":{"code":"…","message":"…"}}`. Pagination: `?limit=&cursor=<ulid>`.
servers:
  - url: /api/v1
tags:
  - {name: auth}
  - {name: admin}
  - {name: consultant}
  - {name: developer}
  - {name: tasks}
  - {name: messaging}
  - {name: shared}
  - {name: internal}

paths:
  /auth/register:
    post:
      tags: [auth]
      summary: Self-register a developer account
      requestBody: {$ref: "#/components/requestBodies/Register"}
      responses:
        "201": {description: Account created and session started}
        "409": {description: Email already registered}
  /auth/login:
    post:
      tags: [auth]
      summary: Local login
      requestBody: {$ref: "#/components/requestBodies/Login"}
      responses:
        "200": {description: "Session cookies set; body: user + mustChangePassword"}
        "401": {description: Invalid credentials}
        "429": {description: Rate limited (10 attempts / 15 min / IP+email)}
  /auth/logout:
    post: {tags: [auth], summary: Revoke the current session, responses: {"204": {description: Logged out}}}
  /auth/logout-all:
    post: {tags: [auth], summary: Revoke every session of the current user, responses: {"200": {description: Count of revoked sessions}}}
  /auth/change-password:
    post:
      tags: [auth]
      summary: Change the local password (revokes other sessions)
      responses: {"204": {description: Changed}, "401": {description: Wrong current password}}
  /auth/forgot:
    post:
      tags: [auth]
      summary: Request a password-reset email (SMTP required)
      responses: {"200": {description: Uniform response}, "503": {description: SMTP not configured}}
  /auth/reset:
    post:
      tags: [auth]
      summary: Set a new password with a one-shot token
      responses: {"204": {description: Password set}, "400": {description: Invalid or expired token}}
  /auth/me:
    get: {tags: [auth], summary: Current user, responses: {"200": {description: User profile + flags}}}
  /auth/oidc/login:
    get: {tags: [auth], summary: Start the OIDC code flow with PKCE (302 to the IdP), responses: {"302": {description: Redirect}}}
  /auth/oidc/callback:
    get: {tags: [auth], summary: OIDC redirect URI (links by verified email or creates a developer), responses: {"302": {description: Redirect home or to /login?error=…}}}

  /admin/users:
    get:
      tags: [admin]
      summary: List/search users
      parameters:
        - {name: q, in: query, schema: {type: string}}
        - {name: role, in: query, schema: {type: string, enum: [admin, consultant, developer]}}
      responses: {"200": {description: Users + nextCursor}}
  /admin/users/{id}:
    patch:
      tags: [admin]
      summary: "Update roles / disabled / name / force password reset"
      responses: {"200": {description: Updated user}, "400": {description: Self-lockout guard}, "409": {description: Version conflict}}
    delete: {tags: [admin], summary: Delete a user and their sessions, responses: {"204": {description: Deleted}}}
  /admin/customers:
    get: {tags: [admin], summary: List customers (includeArchived=true optional), responses: {"200": {description: Customers (credentials never returned)}}}
    post:
      tags: [admin]
      summary: Create a customer with per-system credentials (encrypted at rest)
      requestBody: {$ref: "#/components/requestBodies/Customer"}
      responses: {"201": {description: Created}, "409": {description: Name taken}}
  /admin/customers/{id}:
    get: {tags: [admin], summary: Get one customer, responses: {"200": {description: Customer}}}
    patch: {tags: [admin], summary: Partial update (credentials rotate when supplied), responses: {"200": {description: Updated}, "409": {description: Version conflict}}}
    delete: {tags: [admin], summary: Delete (blocked while tasks exist), responses: {"204": {description: Deleted}, "409": {description: Has tasks — archive instead}}}
  /admin/customers/test-connection:
    post: {tags: [admin], summary: Test unsaved credentials (wizard), responses: {"200": {description: "{ok, latencyMs, error?}"}}}
  /admin/customers/{id}/test-connection:
    post: {tags: [admin], summary: Test the stored connection, responses: {"200": {description: "{ok, latencyMs, error?}"}}}
  /admin/customers/{id}/sync-now:
    post: {tags: [admin], summary: Trigger an immediate sync poll, responses: {"202": {description: Queued}}}
  /admin/settings:
    get: {tags: [admin], summary: Runtime settings, responses: {"200": {description: Settings}}}
    patch: {tags: [admin], summary: Update runtime settings (atomizer URL override, branding, …), responses: {"200": {description: Updated settings}}}
  /admin/audit-log:
    get:
      tags: [admin]
      summary: Audit log of privileged mutations
      parameters:
        - {name: actorId, in: query, schema: {type: string}}
        - {name: entityId, in: query, schema: {type: string}}
        - {name: cursor, in: query, schema: {type: string}}
      responses: {"200": {description: Entries + nextCursor}}
  /admin/service-status:
    get: {tags: [admin], summary: "Health/latency/breaker for atomizer + work performer, sync workers, job queue (§11.17)", responses: {"200": {description: Status payload}}}

  /consultant/board:
    get:
      tags: [consultant]
      summary: Atomization board (imported…claim_requested tasks of own customers)
      parameters:
        - {name: customerId, in: query, schema: {type: string}}
        - {name: status, in: query, schema: {type: string}}
      responses: {"200": {description: Customers + tasks}}
  /consultant/reviews:
    get: {tags: [consultant], summary: Tasks in review for own customers, responses: {"200": {description: Tasks}}}
  /consultant/pool:
    get: {tags: [consultant], summary: Global developer pool with membership flags, responses: {"200": {description: Developers}}}
    post: {tags: [consultant], summary: Add a developer to my pool, responses: {"201": {description: Added}, "409": {description: Already in pool}}}
  /consultant/pool/{developerId}:
    delete: {tags: [consultant], summary: Remove a developer from my pool, responses: {"204": {description: Removed}}}
  /consultant/metrics:
    get:
      tags: [consultant]
      summary: "Aggregations per developer/customer + lead time + board depth (§6.2); format=csv exports the ledger"
      parameters:
        - {name: customerId, in: query, schema: {type: string}}
        - {name: developerId, in: query, schema: {type: string}}
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
        - {name: format, in: query, schema: {type: string, enum: [csv]}}
      responses: {"200": {description: Metrics JSON or CSV}}

  /board:
    get:
      tags: [developer]
      summary: Bounty board (published tasks of consultants who pooled me)
      parameters:
        - {name: customerId, in: query, schema: {type: string}}
        - {name: q, in: query, schema: {type: string}, description: Mongo text search}
        - {name: minBounty, in: query, schema: {type: number}}
        - {name: sort, in: query, schema: {type: string, enum: ["", bounty]}}
      responses: {"200": {description: Tasks + customers}}
  /my-tasks:
    get: {tags: [developer], summary: My kanban (assigned…approved), responses: {"200": {description: Tasks}}}
  /developer/metrics:
    get:
      tags: [developer]
      summary: "Own §6.2 metrics; format=csv exports the ledger"
      parameters:
        - {name: from, in: query, schema: {type: string, format: date}}
        - {name: to, in: query, schema: {type: string, format: date}}
        - {name: format, in: query, schema: {type: string, enum: [csv]}}
      responses: {"200": {description: Metrics JSON or CSV}}
  /leaderboard:
    get: {tags: [shared], summary: Top developers by bounty (opt-out honored), responses: {"200": {description: Leaderboard}}}

  /tasks/{id}:
    get: {tags: [tasks], summary: Task detail incl. timeline (role-scoped), responses: {"200": {description: Task}, "403": {description: No access}}}
    patch: {tags: [consultant], summary: "Edit title/description/AC/coefficient/budget (version-checked, bounty recomputed; cascadeBudget optional)", responses: {"200": {description: Updated task}, "409": {description: Conflict or immutable}}}
  /tasks/{id}/subdivide:
    post: {tags: [consultant], summary: "Queue AI subdivision (§5.1); re-run needs confirmReplace", responses: {"202": {description: "{jobId}"}, "409": {description: Bad status / confirm required}}}
  /tasks/{id}/extend:
    post: {tags: [consultant], summary: Queue AI extension (note required), responses: {"202": {description: "{jobId}"}}}
  /tasks/{id}/publish:
    post: {tags: [consultant], summary: Publish an atomized task to the board, responses: {"200": {description: Task}, "409": {description: Illegal transition}}}
  /tasks/publish:
    post: {tags: [consultant], summary: "Bulk publish {ids:[…]}", responses: {"200": {description: "{published, failed}"}}}
  /tasks/archive:
    post: {tags: [consultant], summary: "Bulk archive {ids:[…]}", responses: {"200": {description: "{archived, failed}"}}}
  /tasks/{id}/archive:
    post: {tags: [tasks], summary: Archive (consultant/admin), responses: {"204": {description: Archived}}}
  /tasks/{id}/claim:
    post: {tags: [developer], summary: Request assignment (optional pitch note), responses: {"204": {description: Requested}, "409": {description: Already requested / not open}}}
  /tasks/{id}/claim/withdraw:
    post: {tags: [developer], summary: Withdraw my claim (back to published when none remain), responses: {"204": {description: Withdrawn}}}
  /tasks/{id}/approve-claim:
    post: {tags: [consultant], summary: "Approve one developer {developerId}; others are declined + notified", responses: {"204": {description: Assigned}}}
  /tasks/{id}/decline-claim:
    post: {tags: [consultant], summary: Decline one claim, responses: {"204": {description: Declined}}}
  /tasks/{id}/assign-ai:
    post: {tags: [consultant], summary: "Create a Work Performer job (§5.2) {context:{repositoryUrl,branch,instructions}}", responses: {"202": {description: "{jobId}"}, "502": {description: Performer rejected the job}}}
  /tasks/{id}/unassign:
    post: {tags: [consultant], summary: Return an assigned/in-progress task to the board, responses: {"204": {description: Unassigned}}}
  /tasks/{id}/start:
    post: {tags: [developer], summary: Start work (assigned or changes_requested), responses: {"204": {description: In progress}}}
  /tasks/{id}/submit-review:
    post: {tags: [developer], summary: Submit for review, responses: {"204": {description: In review}}}
  /tasks/{id}/abandon:
    post: {tags: [developer], summary: Abandon back to the board, responses: {"204": {description: Published}}}
  /tasks/{id}/comments:
    post: {tags: [tasks], summary: Add a sanitized comment (@mentions notify), responses: {"201": {description: Comment}}}
  /tasks/{id}/time:
    post: {tags: [developer], summary: "Log time {minutes, note}", responses: {"201": {description: Entry}}}
  /tasks/{id}/review:
    post: {tags: [consultant], summary: "Review {decision: approve|request_changes, note, checklist[]}; approve freezes the bounty into bountyAwards", responses: {"204": {description: Reviewed}}}

  /conversations:
    get: {tags: [messaging], summary: My conversations with unread counts, responses: {"200": {description: Conversations}}}
    post: {tags: [messaging], summary: "Create dm (deduplicated) / group / project conversation", responses: {"200": {description: Existing dm}, "201": {description: Created}}}
  /conversations/{id}/messages:
    get:
      tags: [messaging]
      summary: Message history (newest page; cursor pages backwards)
      parameters: [{name: cursor, in: query, schema: {type: string}}]
      responses: {"200": {description: Messages chronological}}
    post: {tags: [messaging], summary: "Send {body (sanitized rich text), attachments:[fileIds]}", responses: {"201": {description: Message}}}
  /conversations/{id}/read:
    post: {tags: [messaging], summary: Mark all messages read, responses: {"200": {description: "{marked}"}}}

  /files:
    post: {tags: [shared], summary: "Upload (multipart field `file`; scope=chat default, task for consultants)", responses: {"201": {description: "{fileId, name, mimeType, size, isImage}"}, "413": {description: Exceeds MAX_UPLOAD_MB}}}
  /profile:
    get: {tags: [shared], summary: Own profile, responses: {"200": {description: User}}}
    patch: {tags: [shared], summary: "Partial profile update (name, bio, contact, extra, settings.theme/leaderboardOptOut); optional version → 409 on conflict", responses: {"200": {description: Updated user}}}
  /profile/avatar:
    post: {tags: [shared], summary: Upload avatar (must sniff as an image), responses: {"200": {description: "{fileId}"}}}
  /users:
    get: {tags: [shared], summary: User directory search (name/email) for messaging, responses: {"200": {description: Users}}}
  /users/{id}/card:
    get: {tags: [shared], summary: Public profile card (hover cards), responses: {"200": {description: Card}}}
  /notifications:
    get: {tags: [shared], summary: My notifications + unread count, responses: {"200": {description: Notifications}}}
  /notifications/read:
    post: {tags: [shared], summary: "Mark read {ids:[]} (empty = all)", responses: {"200": {description: "{marked}"}}}
  /service-health:
    get: {tags: [shared], summary: Atomizer + work-performer health/breaker for UI gating, responses: {"200": {description: Health}}}

  /internal/work-results:
    post:
      tags: [internal]
      summary: "Work Performer callback (§5.2): HMAC `X-Signature` over the raw body with WORK_PERFORMER_TOKEN; idempotent by jobId; artifacts ingested into GridFS"
      responses:
        "200": {description: Accepted or duplicate_ignored}
        "401": {description: Bad signature}

components:
  requestBodies:
    Register:
      content:
        application/json:
          schema:
            type: object
            required: [email, name, password]
            properties:
              email: {type: string, format: email}
              name: {type: string}
              password: {type: string, minLength: 8}
    Login:
      content:
        application/json:
          schema:
            type: object
            required: [email, password]
            properties:
              email: {type: string}
              password: {type: string}
    Customer:
      content:
        application/json:
          schema:
            type: object
            required: [name, type]
            properties:
              name: {type: string}
              type: {type: string, enum: [jira, azure_devops, youtrack, wekan, demo]}
              baseUrl: {type: string}
              projectKey: {type: string}
              pollIntervalSec: {type: integer, minimum: 10}
              defaultBudget: {type: number}
              consultantIds: {type: array, items: {type: string}}
              credentials:
                type: object
                description: "jira: {email, apiToken} · azure_devops: {organization, project, pat} · youtrack: {permanentToken} · wekan: {username, password} (projectKey = board id) · demo: {}"
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: {type: string}
            message: {type: string}
