openapi: 3.1.0

info:
  title: Optiwork Projects API
  version: v2026.07.23.1005
  summary: Kanban project management (boards, columns, tasks) for the Optiwork suite.
  description: |
    The **Optiwork Projects** service provides kanban-style project management:
    hierarchical **boards** (projects), **columns** (task lists), **cards** (tasks),
    plus tags, checklists, comments, time tracking, file attachments, custom fields
    and recurring tasks.

    ### Base URL
    All endpoints are served under the fixed Projects host:
    `https://projects.work.optidata.cloud/api`.

    ### Authentication
    Every endpoint (except `GET /ping`) requires a **Bearer JWT passport**. Your
    personal passport is available in Optiwork at
    [https://work.optidata.cloud/user/settings/security](https://work.optidata.cloud/user/settings/security)
    (**My account > Profile > Security**), where it is shown as the
    **authentication key**. Send it on every request:

    ```
    Authorization: Bearer opk_xxxxxxxxxxxxxxxxxxxxxxxx
    Accept: application/json
    ```

    Passports are short-lived; when a request returns `401`, get a fresh
    authentication key from the same settings page and retry.

    ### Permissions
    Board access is governed by board membership. Each member has one permission
    level: `view` < `interact` < `edit` < `manage`. Operations note the level they
    require; requests by users without the required level return `403`. Boards may
    also be `public` (visible to every user of the tenant) or `private`
    (members only).

    ### Conventions
    - Identifiers in paths (`{board}`, `{card}`, ...) are the numeric `id` of the
      resource unless stated otherwise. Nested resources are **scope-checked**:
      referencing a card that does not belong to the given board returns `404`.
    - Timestamps are ISO-8601 UTC strings (e.g. `2026-03-10T17:09:05.000000Z`).
    - Validation failures return `422` with a per-field `errors` map.
    - Responses are serialized with numeric-string coercion: numeric fields are
      emitted as JSON numbers.
  contact:
    name: Optidata — Optiwork Platform Team
    url: https://www.optidata.cloud
  license:
    name: Proprietary — © Optidata. All rights reserved.
    url: https://www.optidata.cloud
  x-audience: external

servers:
  - url: https://projects.work.optidata.cloud/api
    description: Optiwork Projects API

security:
  - bearerAuth: []

tags:
  - name: Service
    description: Health check.
  - name: Boards
    description: Projects. Boards can be nested (sub-projects), archived, duplicated, imported and exported.
  - name: Board Members
    description: Membership listing and the calling member's per-board preferences.
  - name: Columns
    description: Task lists inside a board. Ordering of columns and of the cards within them.
  - name: Cards
    description: Tasks. Creation, update, archival, copy and batch operations.
  - name: Card Files
    description: File attachments on cards, backed by Optiwork Drive.
  - name: Card Assignees & Tags
    description: Assigning users and tags to a card.
  - name: Card Recurrence
    description: Repeat rules that automatically generate new occurrences of a card.
  - name: Comments
    description: Threaded discussion on a card.
  - name: Time Tracking
    description: Start/stop timers and manage time entries on a card.
  - name: Checklists
    description: Checklists and checklist items inside a card.
  - name: Custom Fields
    description: Custom field values on cards.
  - name: Custom Field Definitions
    description: Definitions of custom fields, either per board or global (tenant-wide).
  - name: Tags
    description: Labels. Board-scoped tags and common (tenant-wide) tags.
  - name: Contact Links
    description: Linking Optiwork Contacts records to boards and cards.
  - name: Dashboard
    description: Cross-board summaries for a user.

paths:
  # ─────────────────────────────── Service ───────────────────────────────
  /ping:
    get:
      operationId: ping
      tags: [Service]
      summary: Health check
      description: Public liveness probe. Requires no authentication.
      security: []
      responses:
        "200":
          description: Service is up.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pong:
                    type: boolean
              example: { pong: true }

  # ─────────────────────────────── Boards ───────────────────────────────
  /boards:
    get:
      operationId: listBoards
      tags: [Boards]
      summary: List boards the caller is a member of
      description: |
        Returns every board the authenticated user is a member of, as a flat
        array. When `board_id` is given, that board and all of its descendants
        are excluded from the result (useful to pick a new parent for a board
        without creating a cycle).
      parameters:
        - name: board_id
          in: query
          required: false
          description: Exclude this board and all of its descendant boards from the result. Must reference an existing board.
          schema: { type: integer }
      responses:
        "200":
          description: Boards the user belongs to.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Board" }
              example:
                - id: 42
                  user_id: 7
                  group_id: null
                  cuid: "a1b2c3d4-0000-4111-8222-333344445555"
                  name: Website Redesign
                  description: Q3 marketing site refresh
                  icon: "🎨"
                  parent_id: null
                  archived_at: null
                  visibility: private
                  order_cards_by: order
                  order_cards_direction: asc
                  created_at: "2026-03-10T17:09:05.000000Z"
                  updated_at: "2026-03-10T17:09:05.000000Z"
                  deleted_at: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createBoard
      tags: [Boards]
      summary: Create a board
      description: |
        Creates a board owned by the authenticated user, attaches the listed
        members with their permission levels, and optionally seeds columns from a
        project template. Members receive an in-app notification.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, user_permissions]
              properties:
                name:
                  type: string
                  maxLength: 80
                description:
                  type: [string, "null"]
                  maxLength: 600
                icon:
                  type: [string, "null"]
                  maxLength: 14
                  description: Emoji used as the board icon.
                parent_id:
                  type: [integer, "null"]
                  description: Id of an existing board to nest this board under.
                user_permissions:
                  type: object
                  description: |
                    Map of user id → permission level for the initial members.
                    Use `none` to skip a user. The creator is always the owner.
                  additionalProperties:
                    type: string
                    enum: [none, view, interact, edit, manage]
                template:
                  type: [string, "null"]
                  description: Optional project template used to seed the board's columns.
            example:
              name: Website Redesign
              description: Q3 marketing site refresh
              icon: "🎨"
              parent_id: null
              user_permissions:
                "8": edit
                "9": manage
      responses:
        "200":
          description: The created board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/tree:
    get:
      operationId: getBoardTree
      tags: [Boards]
      summary: Get the caller's boards as a tree
      description: |
        Returns the boards the user can edit or manage (including owned boards)
        as a nested tree of `{id, name, icon, children}` nodes. Useful to render
        a project picker.
      responses:
        "200":
          description: Nested board tree.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BoardTreeNode" }
              example:
                data:
                  - id: 42
                    name: Website Redesign
                    icon: "🎨"
                    children:
                      - { id: 51, name: Landing pages, icon: "📄", children: [] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /boards/order:
    patch:
      operationId: updateBoardOrder
      tags: [Boards]
      summary: Reorder the caller's board list
      description: |
        Persists the personal display order of the caller's boards (the sidebar
        order). Affects only the authenticated user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [board_ids]
              properties:
                board_ids:
                  type: array
                  minItems: 1
                  items: { type: integer }
                  description: Board ids in the desired display order.
            example: { board_ids: [42, 51, 12] }
      responses:
        "204": { description: Order saved. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/info/batch:
    get:
      operationId: getBoardsInfoBatch
      tags: [Boards]
      summary: Get selected fields for a batch of boards
      description: |
        Returns lightweight info for up to a batch of boards by id, restricted to
        boards the caller is a member of (inaccessible ids are silently omitted).
        By default returns `id`, `name`, `icon` and `cuid`; use
        `options[fields][]` to choose fields and `options[with][]=parent` to
        embed the parent board's `{id, name, icon}`.
      parameters:
        - name: "ids[]"
          in: query
          required: true
          schema:
            type: array
            items: { type: integer }
          description: Board ids to fetch.
        - name: "options[fields][]"
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
              enum: [name, icon, cuid, parent_id, description, visibility, archived_at]
          description: Fields to include (id is always included).
        - name: "options[with][]"
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
              enum: [parent]
          description: Relations to embed.
      responses:
        "200":
          description: Requested boards (accessible ones only).
          content:
            application/json:
              schema:
                type: object
                properties:
                  boards:
                    type: array
                    items:
                      type: object
                      description: Partial board — contains `id` plus the requested fields/relations.
              example:
                boards:
                  - { id: 42, name: Website Redesign, icon: "🎨", cuid: "a1b2c3d4-0000-4111-8222-333344445555" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}:
    parameters:
      - $ref: "#/components/parameters/boardId"
    get:
      operationId: getBoard
      tags: [Boards]
      summary: Get a board with its full working set
      description: |
        Returns the board with its members, tags, columns, cards, custom field
        definitions, plus the caller's permissions and per-board preferences —
        everything needed to render the kanban view. When `attributes[]` is
        given, returns only those board attributes instead (wrapped in a
        `board` key). Requires `view` permission.
      parameters:
        - name: "attributes[]"
          in: query
          required: false
          schema:
            type: array
            items: { type: string }
          description: Return only these board attributes (must be valid board attribute names).
      responses:
        "200":
          description: Full board payload (or partial when `attributes[]` was used).
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: "#/components/schemas/BoardDetail"
                  - type: object
                    description: Partial response when `attributes[]` is used.
                    required: [board]
                    properties:
                      board:
                        type: object
                        additionalProperties: true
              examples:
                full:
                  summary: Full board
                  value:
                    id: 42
                    user_id: 7
                    group_id: null
                    cuid: "a1b2c3d4-0000-4111-8222-333344445555"
                    name: Website Redesign
                    description: null
                    icon: "🎨"
                    parent_id: null
                    archived_at: null
                    visibility: private
                    order_cards_by: order
                    order_cards_direction: asc
                    created_at: "2026-03-10T17:09:05.000000Z"
                    updated_at: "2026-03-10T17:09:05.000000Z"
                    deleted_at: null
                    members:
                      "7":
                        id: 7
                        uuid: "0b0d2f1e-6a3c-4f7d-9a21-7c1f2e3d4a5b"
                        name: Jane Doe
                        avatar_url: "https://cdn.example/avatars/jane.webp"
                        pivot: { board_id: 42, user_id: 7, permission: manage, view_mode: view_kanban }
                    tags:
                      "88": { id: 88, name: frontend, description: null, color: "#52eb00" }
                    columns:
                      "301": { id: 301, board_id: 42, name: To do, description: "", icon: null, order: 1, archived_at: null, sort_order: null, color: null }
                    cards:
                      - { id: 9001, board_id: 42, column_id: 301, title: Draft homepage copy, number: 1, order: 1, status: null, due_date: null }
                    custom_field_definitions: []
                    permissions: { view: true, interact: true, edit: true, manage: true }
                    preferences:
                      view_mode: view_kanban
                      notification: { notification_mode: all, is_notified_through_app: true, is_notified_through_email: true }
                partial:
                  summary: attributes[]=name&attributes[]=icon
                  value:
                    board: { name: Website Redesign, icon: "🎨" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateBoard
      tags: [Boards]
      summary: Update a board
      description: |
        Partially updates the board's attributes. Requires `edit` permission;
        changing `members` or `visibility` requires `manage`. When `members` is
        sent the full member list is synchronized (users omitted from the list
        are removed). `visibility` and `members` must be sent together.
        Attribute changes may notify board members.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                description: { type: [string, "null"], maxLength: 600 }
                icon: { type: [string, "null"], maxLength: 14 }
                parent_id:
                  type: [integer, "null"]
                  description: New parent board (must exist; cycles are rejected).
                visibility:
                  type: string
                  enum: [private, public]
                  description: Required when `members` is present. Requires `manage` permission.
                members:
                  type: array
                  minItems: 1
                  description: Full member list to synchronize. Required when `visibility` is present. Requires `manage` permission.
                  items:
                    type: object
                    required: [id, permission]
                    properties:
                      id: { type: integer, description: User id. }
                      permission:
                        type: string
                        enum: [view, interact, edit, manage]
            example:
              name: Website Redesign (Q3)
              visibility: private
              members:
                - { id: 7, permission: manage }
                - { id: 8, permission: edit }
      responses:
        "200":
          description: The updated board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteBoard
      tags: [Boards]
      summary: Delete a board
      description: |
        Soft-deletes the board **and all of its descendant boards**, recursively.
        Requires `manage` permission (delete policy).
      responses:
        "200":
          description: The deleted board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/search:
    get:
      operationId: searchBoard
      tags: [Boards]
      summary: Search cards and columns within a board
      description: |
        Filters the board's cards and columns by free-text query, tags,
        assignees, statuses, due-date buckets and custom field values. The
        response has the same shape as *Get a board* but with `columns` and
        `cards` restricted to the matches (plus a `cards_summary` with per-card
        counters). Requires `view` permission.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - name: query
          in: query
          schema: { type: [string, "null"] }
          description: Free-text search over card titles/content.
        - name: "by[tags][]"
          in: query
          schema: { type: array, items: { type: integer } }
          description: Only cards having any of these tag ids.
        - name: "by[assignees][]"
          in: query
          schema: { type: array, items: { type: integer } }
          description: Only cards assigned to any of these user ids.
        - name: "by[statuses][]"
          in: query
          schema:
            type: array
            items:
              type: string
              enum: [OPEN, CLOSED, ARCHIVED, OVERDUE, NEAR, FAR]
          description: Only cards in any of these statuses / due-date statuses.
        - name: "by[dueDate][]"
          in: query
          schema:
            type: array
            items:
              type: string
              enum: [overdue, today, tomorrow, this-week, next-week, next-month, future, no-date, completed]
          description: Due-date buckets to match.
        - name: "by[customFields]"
          in: query
          style: deepObject
          explode: true
          schema:
            type: array
            items:
              type: object
              required: [definitionId, operator]
              properties:
                definitionId: { type: integer }
                operator:
                  type: string
                  enum: [contains, equals, gt, gte, lt, lte, between, is, is_not, is_any_of, before, after, is_empty, is_not_empty]
                value:
                  description: Comparison value (type depends on the field type; omitted for is_empty/is_not_empty).
          description: Custom-field filters, e.g. `by[customFields][0][definitionId]=12&by[customFields][0][operator]=equals&by[customFields][0][value]=High`.
        - name: "scope[archived]"
          in: query
          schema: { type: boolean }
          description: Include archived cards/columns.
        - name: target
          in: query
          schema:
            type: string
            enum: ["App\\Models\\Card", "App\\Models\\Column", "App\\Models\\CardTemplate"]
          description: Restrict matching to one entity type; omit to search cards and columns.
      responses:
        "200":
          description: Board payload restricted to matching columns/cards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BoardDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/archive:
    post:
      operationId: archiveBoard
      tags: [Boards]
      summary: Archive a board
      description: Archives the board and, recursively, all of its descendant boards. Requires archive permission (`manage`).
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      responses:
        "200":
          description: The archived board (`archived_at` set).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/unarchive:
    post:
      operationId: unarchiveBoard
      tags: [Boards]
      summary: Unarchive a board
      description: |
        Restores an archived board and the descendants archived with it. If the
        board's parent is still archived, the board is detached to root level.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      responses:
        "200":
          description: The restored board (`archived_at` null).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/duplicate:
    post:
      operationId: duplicateBoard
      tags: [Boards]
      summary: Duplicate a board
      description: Creates a copy of the board (structure and content) owned by the caller. Requires duplicate permission.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      responses:
        "200":
          description: The newly created copy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Board" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/import:
    post:
      operationId: importBoard
      tags: [Boards]
      summary: Import cards into a board from an external tool
      description: |
        Imports a project exported from an external source (currently Trello
        JSON exports) into this board. Send the export file as multipart
        form-data. Requires import permission.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, source]
              properties:
                file:
                  type: string
                  format: binary
                  description: The export file (`.json` or `.txt`).
                source:
                  type: string
                  enum: [TRELLO]
                  description: Source tool of the export.
      responses:
        "200":
          description: Import result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/export:
    get:
      operationId: exportBoard
      tags: [Boards]
      summary: Export a board as a file download
      description: |
        Exports the board's cards to CSV, Excel or JSON and streams the file as
        a download. Requires export permission.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - name: format
          in: query
          required: true
          schema:
            type: string
            enum: [csv, excel, json]
        - name: "fields[]"
          in: query
          required: true
          schema:
            type: array
            items:
              type: string
              enum: [assignees, content, creation_date, due_date, pinned_at, list_name, progress, tags, task_title, time_track]
          description: Card fields to include in the export.
      responses:
        "200":
          description: The exported file (content type depends on `format`).
          content:
            text/csv:
              schema: { type: string }
            application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
              schema: { type: string, format: binary }
            application/json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/export/drive:
    post:
      operationId: exportBoardToDrive
      tags: [Boards]
      summary: Export a board to Optiwork Drive
      description: |
        Exports the board (same formats and fields as *Export a board*) and
        uploads the resulting file to a folder (node) in Optiwork Drive instead
        of streaming it back. Requires export permission.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [format, fields, node]
              properties:
                format:
                  type: string
                  enum: [csv, excel, json]
                fields:
                  type: array
                  items:
                    type: string
                    enum: [assignees, content, creation_date, due_date, pinned_at, list_name, progress, tags, task_title, time_track]
                node:
                  type: object
                  description: Target Optiwork Drive node (folder) descriptor where the file is uploaded.
            example:
              format: csv
              fields: [task_title, list_name, due_date, assignees]
              node: { id: 12345, drive_id: 1 }
      responses:
        "200":
          description: Upload result with the created Drive file metadata.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: Drive upload result (file metadata as returned by Optiwork Drive).
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/visibility:
    put:
      operationId: updateBoardVisibility
      tags: [Boards]
      summary: Change a board's visibility
      description: Switches the board between `private` (members only) and `public` (all tenant users can view). Requires `manage` permission.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                visibility:
                  type: string
                  enum: [private, public]
            example: { visibility: public }
      responses:
        "200":
          description: New visibility.
          content:
            application/json:
              schema:
                type: object
                properties:
                  visibility: { type: string, enum: [private, public] }
              example: { visibility: public }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ─────────────────────────── Board Members ───────────────────────────
  /boards/{board}/members:
    get:
      operationId: listBoardMembers
      tags: [Board Members]
      summary: List the members of a board
      description: Returns all users who are members of the board, including soft-deleted users (so past activity can still be attributed). Membership metadata is in each user's `pivot`.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      responses:
        "200":
          description: Board members.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/BoardMemberUser" }
              example:
                - id: 7
                  uuid: "0b0d2f1e-6a3c-4f7d-9a21-7c1f2e3d4a5b"
                  username: jane.doe@acme.example
                  email: jane.doe@acme.example
                  name: Jane Doe
                  company_name: Acme Corp
                  role: user
                  status: active
                  avatar_url: "https://cdn.example/avatars/jane.webp"
                  deleted_at: null
                  pivot: { board_id: 42, user_id: 7, permission: manage, view_mode: view_kanban }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/members/assignables:
    get:
      operationId: listAssignableUsers
      tags: [Board Members]
      summary: Search users that could be added to the board
      description: |
        Searches the tenant's users by name or email (excluding the caller) so
        they can be invited as board members. Returns at most 50 users ordered
        by name.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - name: query
          in: query
          required: true
          schema: { type: string }
          description: Name/email search string.
      responses:
        "200":
          description: Matching users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  assignable:
                    type: array
                    items: { $ref: "#/components/schemas/UserSlim" }
              example:
                assignable:
                  - { id: 8, uuid: "9f8e7d6c-5b4a-3f2e-1d0c-b9a8c7d6e5f4", name: John Smith, email: john.smith@acme.example }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/members/mentionables:
    get:
      operationId: listMentionableUsers
      tags: [Board Members]
      summary: Search users that can be @-mentioned on the board
      description: Returns the board's members (max 50, ordered by name), optionally filtered by a name/email substring — the candidates for @-mentions in card content and comments.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - name: filter
          in: query
          required: false
          schema: { type: [string, "null"] }
          description: Name/email substring filter.
      responses:
        "200":
          description: Mentionable users.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/UserSlim" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/members/myself/view-mode:
    patch:
      operationId: updateMyViewMode
      tags: [Board Members]
      summary: Set the caller's view mode for this board
      description: Persists whether the caller sees this board as a kanban or a list. Affects only the authenticated member.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                viewMode:
                  type: string
                  enum: [view_kanban, view_list]
            example: { viewMode: view_list }
      responses:
        "200":
          description: The stored view mode.
          content:
            application/json:
              schema:
                type: object
                properties:
                  view_mode: { type: string, enum: [view_kanban, view_list] }
              example: { view_mode: view_list }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/members/myself/notifications/preferences:
    patch:
      operationId: updateMyNotificationPreferences
      tags: [Board Members]
      summary: Set the caller's notification preferences for this board
      description: |
        Updates which events on this board notify the caller (`all` activity,
        only cards where they are an `assignee`, only `mentions`, or `silent`)
        and through which channels (in-app, email). Affects only the
        authenticated member.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                notification_mode:
                  type: string
                  enum: [all, assignee, mentions, silent]
                is_notified_through_app: { type: boolean }
                is_notified_through_email: { type: boolean }
            example:
              notification_mode: assignee
              is_notified_through_app: true
              is_notified_through_email: false
      responses:
        "200":
          description: The stored preference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notification_mode: { type: string, enum: [all, assignee, mentions, silent] }
                  is_notified_through_app: { type: boolean }
                  is_notified_through_email: { type: boolean }
              example:
                notification_mode: assignee
                is_notified_through_app: true
                is_notified_through_email: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ─────────────────────────────── Columns ───────────────────────────────
  /boards/{board}/columns:
    parameters: [ { $ref: "#/components/parameters/boardId" } ]
    get:
      operationId: listColumns
      tags: [Columns]
      summary: List the columns of a board
      description: Returns the board's active (non-archived) columns ordered by position.
      responses:
        "200":
          description: Columns.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Column" }
              example:
                - { id: 301, board_id: 42, name: To do, description: "", icon: null, order: 1, sort_order: null, color: null, archived_at: null, created_at: "2026-03-10T17:09:05.000000Z", updated_at: "2026-03-10T17:09:05.000000Z", deleted_at: null }
                - { id: 302, board_id: 42, name: Doing, description: "", icon: null, order: 2, sort_order: null, color: "#3b82f6", archived_at: null, created_at: "2026-03-10T17:09:05.000000Z", updated_at: "2026-03-10T17:09:05.000000Z", deleted_at: null }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createColumn
      tags: [Columns]
      summary: Create a column
      description: Adds a column at the end of the board. Requires `edit` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ColumnWrite" }
            example: { name: Review, description: Awaiting code review, color: "#8b5cf6" }
      responses:
        "200":
          description: The created column.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/columns/order:
    patch:
      operationId: reorderColumns
      tags: [Columns]
      summary: Reorder the board's columns
      description: |
        Bulk-updates the position of the board's columns. Every referenced id
        must belong to the board, otherwise the request fails with `400`.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [columnsOrder]
              properties:
                columnsOrder:
                  type: array
                  items:
                    type: object
                    required: [id, order]
                    properties:
                      id: { type: integer }
                      order: { type: integer }
            example:
              columnsOrder:
                - { id: 302, order: 1 }
                - { id: 301, order: 2 }
      responses:
        "200":
          description: The applied order (echo of the request).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    order: { type: integer }
        "400":
          description: A referenced column does not belong to the board.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/columns/delete/batch:
    post:
      operationId: batchDeleteColumns
      tags: [Columns]
      summary: Delete several columns at once
      description: Soft-deletes the listed columns. Requires the batch-delete permission on the board.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdBatch" }
            example: { ids: [301, 305] }
      responses:
        "200":
          description: The deleted columns.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/columns/unarchive/batch:
    post:
      operationId: batchUnarchiveColumns
      tags: [Columns]
      summary: Unarchive several columns at once
      description: Restores the listed archived columns.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdBatch" }
            example: { ids: [303, 304] }
      responses:
        "200":
          description: The restored columns.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/columns/{column}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/columnId"
    patch:
      operationId: updateColumn
      tags: [Columns]
      summary: Update a column
      description: Updates the column's name, description, icon or color. Requires `edit` permission.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ColumnWrite" }
            example: { name: In review, color: "#f59e0b" }
      responses:
        "200":
          description: The updated column.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteColumn
      tags: [Columns]
      summary: Delete a column
      description: Soft-deletes the column and its cards from the board view.
      responses:
        "200":
          description: The deleted column.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/columns/{column}/cards:
    get:
      operationId: listColumnCards
      tags: [Columns]
      summary: List the cards of a column (slim)
      description: Returns the column's cards in display order (pinned first) with a minimal payload — id, column, title, icon and number.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/columnId"
      responses:
        "200":
          description: Slim card list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CardSlim" }
              example:
                data:
                  - { id: 9001, column_id: 301, title: Draft homepage copy, icon: null, number: 1 }
                  - { id: 9002, column_id: 301, title: Choose hero image, icon: "🖼️", number: 2 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/columns/{column}/archive:
    post:
      operationId: archiveColumn
      tags: [Columns]
      summary: Archive a column
      description: Archives the column (and hides its cards from the active board view).
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/columnId"
      responses:
        "200":
          description: The archived column.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/columns/{column}/unarchive:
    post:
      operationId: unarchiveColumn
      tags: [Columns]
      summary: Unarchive a column
      description: Restores an archived column to the active board view.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/columnId"
      responses:
        "200":
          description: The restored column.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/columns/{column}/order:
    post:
      operationId: sortColumnCards
      tags: [Columns]
      summary: Sort a column's cards by a predefined criterion
      description: Re-ranks all ordinary cards in the column by the chosen criterion (`oldest`/`newest` creation date, or `due_date`). Pinned cards keep their pinned status. Broadcasts the new order to other connected clients.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/columnId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order_option]
              properties:
                order_option:
                  type: string
                  enum: [oldest, newest, due_date]
            example: { order_option: due_date }
      responses:
        "200":
          description: The column after sorting.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Column" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/columns/{column}/cards/order:
    patch:
      operationId: moveCards
      tags: [Columns]
      summary: Move / reorder cards within or into a column
      description: |
        Bulk-positions cards inside the column identified in the path — this is
        the drag-and-drop endpoint, used both for reordering within a column and
        for moving a card from another column of the same board (send the card's
        id with its new `order` and `column_id`). All referenced cards must
        exist and belong to a single board, otherwise the request fails with `400`.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/columnId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cardsOrder]
              properties:
                cardsOrder:
                  type: array
                  items:
                    type: object
                    required: [id, order]
                    properties:
                      id: { type: integer, description: Card id. }
                      order: { type: integer, description: New position within the column. }
                      column_id: { type: integer, description: Target column (when moving between columns). }
            example:
              cardsOrder:
                - { id: 9002, order: 1, column_id: 302 }
                - { id: 9001, order: 2, column_id: 302 }
      responses:
        "200":
          description: The applied order (echo of the request).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  additionalProperties: true
        "400":
          description: Referenced cards missing or spanning multiple boards.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ─────────────────────────────── Cards ───────────────────────────────
  /boards/{board}/cards:
    post:
      operationId: createCard
      tags: [Cards]
      summary: Create a card
      description: |
        Creates a card (task) in one of the board's columns. Optionally applies
        a card template, sets a due date, cover color and rich-text content.
        @-mentions inside `content` trigger notifications. Requires `edit`
        permission (create policy).
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [column_id, title]
              properties:
                column_id: { type: integer, description: Target column (must exist). }
                title: { type: string, maxLength: 255 }
                content:
                  type: [string, "null"]
                  description: Rich-text card description (serialized editor document, JSON string).
                type:
                  type: [string, "null"]
                  enum: [ORDINARY, null]
                  description: Card type; only `ORDINARY` can be created directly.
                status:
                  type: [string, "null"]
                  enum: [OPEN, CLOSED, ARCHIVED, null]
                icon: { type: [string, "null"] }
                order: { type: [integer, "null"], description: Position within the column. }
                due_date:
                  type: [string, "null"]
                  format: date-time
                template_id:
                  type: [integer, "null"]
                  description: Card template to apply (must be visible on this board).
                cover_color:
                  type: [string, "null"]
                  enum: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280", "#000000", null]
                source:
                  type: [string, "null"]
                  description: Originating Optiwork app nickname when the card is created from another app (e.g. `mail`).
            example:
              column_id: 301
              title: Draft homepage copy
              content: "{}"
              due_date: "2026-08-01T12:00:00Z"
              cover_color: "#3b82f6"
      responses:
        "200":
          description: The created card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/delete/batch:
    post:
      operationId: batchDeleteCards
      tags: [Cards]
      summary: Delete several cards at once
      description: Soft-deletes the listed cards.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdBatch" }
            example: { ids: [9001, 9002] }
      responses:
        "204": { description: Cards deleted. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/unarchive/batch:
    post:
      operationId: batchUnarchiveCards
      tags: [Cards]
      summary: Unarchive several cards at once
      description: Restores the listed archived cards.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdBatch" }
            example: { ids: [9003] }
      responses:
        "204": { description: Cards restored. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
    get:
      operationId: getCard
      tags: [Cards]
      summary: Get a card
      description: |
        Returns the full card, including derived attributes: tag/assignee id
        lists, comment/file/custom-field counters, due-date status and
        human-readable due-date strings, and whether the caller has a running
        timer on it.
      responses:
        "200":
          description: The card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
              example:
                id: 9001
                board_id: 42
                column_id: 301
                user_id: 7
                title: Draft homepage copy
                content: "{}"
                type: ORDINARY
                status: null
                icon: null
                order: 1
                number: 1
                due_date: "2026-08-01T12:00:00.000000Z"
                due_date_hash: "b2c3d4e5-1111-4222-8333-444455556666"
                pinned_at: null
                completed_at: null
                archived_at: null
                deleted_at: null
                checklist_id: null
                checklist_items: null
                checklist_items_completed: null
                card_recurrence_id: null
                cover_color: null
                created_at: "2026-03-10T17:09:49.000000Z"
                updated_at: "2026-03-10T17:14:28.000000Z"
                tags_ids: ["88"]
                assignees_ids: ["7"]
                is_time_tracking: null
                comments_count: 0
                files_count: 0
                custom_fields_count: 0
                human_due_date: "Aug 1, 2026"
                short_locale_date: "08/01/2026"
                human_verbose_due_date: "August 1, 2026 12:00 PM"
                due_date_status: FAR
                is_completed: false
                is_recurrent: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateCard
      tags: [Cards]
      summary: Update a card
      description: |
        Partially updates the card. Moving the card is possible by sending
        `column_id` (and `board_id` for cross-board moves). Setting
        `completed_at` marks the card complete. @-mentions added to `content`
        trigger notifications.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 255 }
                content: { type: [string, "null"] }
                board_id: { type: [integer, "null"], description: Target board when moving across boards. }
                column_id: { type: [integer, "null"], description: Target column when moving. }
                status:
                  type: [string, "null"]
                  enum: [OPEN, CLOSED, ARCHIVED, null]
                icon: { type: [string, "null"] }
                order: { type: [integer, "null"] }
                due_date: { type: [string, "null"], format: date-time }
                completed_at: { type: [string, "null"], format: date-time }
                template_id: { type: [integer, "null"] }
                cover_color:
                  type: [string, "null"]
                  enum: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280", "#000000", null]
            example:
              title: Draft homepage copy (v2)
              due_date: "2026-08-05T12:00:00Z"
      responses:
        "200":
          description: The updated card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteCard
      tags: [Cards]
      summary: Delete a card
      description: |
        Soft-deletes the card. If the card is part of a recurrence series, pass
        `stop_recurrence: true` to also stop the series.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                stop_recurrence:
                  type: [boolean, "null"]
                  description: Also stop the card's recurrence series.
            example: { stop_recurrence: true }
      responses:
        "200":
          description: The deleted card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/archive:
    post:
      operationId: archiveCard
      tags: [Cards]
      summary: Archive a card
      description: Sets the card's status to `ARCHIVED` and hides it from the active board view. Broadcast to other connected clients.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      responses:
        "200":
          description: The archived card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/unarchive:
    post:
      operationId: unarchiveCard
      tags: [Cards]
      summary: Unarchive a card
      description: Restores an archived card to its column.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      responses:
        "200":
          description: The restored card.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/copy:
    post:
      operationId: copyCard
      tags: [Cards]
      summary: Duplicate a card in place
      description: Creates a copy of the card in the same column (content, tags, assignees and checklists included).
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      responses:
        "200":
          description: The new copy.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /cards/info/batch:
    get:
      operationId: getCardsInfoBatch
      tags: [Cards]
      summary: Get selected fields for a batch of cards (cross-board)
      description: |
        Returns lightweight info for cards by id across all boards the caller is
        a member of (inaccessible ids are silently omitted). By default returns
        `id`, `title` and `number`; use `options[fields][]` to choose fields and
        `options[with][]` to embed the parent `board` (`{id, name, icon}`) or
        `column` (`{id, name}`).
      parameters:
        - name: "ids[]"
          in: query
          required: true
          schema:
            type: array
            items: { type: integer }
          description: Card ids to fetch.
        - name: "options[fields][]"
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
              enum: [title, number, column_id, status, due_date, archived_at, completed_at]
          description: Fields to include (id is always included).
        - name: "options[with][]"
          in: query
          required: false
          schema:
            type: array
            items:
              type: string
              enum: [board, column]
          description: Relations to embed.
      responses:
        "200":
          description: Requested cards (accessible ones only).
          content:
            application/json:
              schema:
                type: object
                properties:
                  cards:
                    type: array
                    items:
                      type: object
                      description: Partial card — contains `id` plus the requested fields/relations.
              example:
                cards:
                  - { id: 9001, title: Draft homepage copy, number: 1, board_id: 42, board: { id: 42, name: Website Redesign, icon: "🎨" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ───────────────────────────── Card Files ─────────────────────────────
  /boards/{board}/cards/{card}/files:
    post:
      operationId: uploadCardFile
      tags: [Card Files]
      summary: Upload a file to a card
      description: |
        Attaches a file to the card by uploading the binary from the caller's
        machine (multipart form-data with `blob`). The file is stored in the
        tenant's Optiwork Drive. Editor media uploads (`is_editor_upload: true`)
        are restricted to an image/video allow-list with per-type size limits.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, size_bytes, mime_type, blob]
              properties:
                name: { type: string, description: File name. }
                size_bytes: { type: integer, description: File size in bytes. }
                mime_type: { type: string, description: File MIME type. }
                blob: { type: string, format: binary, description: The file content. }
                is_editor_upload:
                  type: boolean
                  description: Set when uploading media from the card's rich-text editor (enables the media allow-list and size limits).
      responses:
        "200":
          description: The created file with access URLs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileUploadResult" }
              example:
                file:
                  id: 5001
                  identifier: "f0e1d2c3-7777-4888-9999-aaaabbbbcccc"
                  download_url: "https://projects.work.optidata.cloud/projects/boards/42/cards/9001/files/f0e1d2c3-7777-4888-9999-aaaabbbbcccc/download"
                  content_url: "https://projects.work.optidata.cloud/projects/boards/42/cards/9001/files/f0e1d2c3-7777-4888-9999-aaaabbbbcccc/content"
                  view_url: "https://drive.work.optidata.cloud/view/abc123"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/files/attach:
    post:
      operationId: attachDriveFileToCard
      tags: [Card Files]
      summary: Attach an existing Optiwork Drive file to a card
      description: |
        Links a file that already lives in the tenant's Optiwork Drive to the
        card, without uploading new content. Provide the Drive node metadata in
        `meta` instead of a binary `blob`.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, size_bytes, mime_type, meta]
              properties:
                name: { type: string }
                size_bytes: { type: integer }
                mime_type: { type: string }
                meta:
                  type: object
                  description: Optiwork Drive node metadata identifying the file to link.
            example:
              name: brand-guidelines.pdf
              size_bytes: 482133
              mime_type: application/pdf
              meta: { node_id: 7788, drive_id: 1 }
      responses:
        "200":
          description: The linked file with access URLs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileUploadResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/files/{file}/download:
    get:
      operationId: downloadCardFile
      tags: [Card Files]
      summary: Download a card file
      description: Streams the file content from Optiwork Drive as an attachment download.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/fileIdentifier"
      responses:
        "200":
          description: The file content (Content-Disposition attachment).
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/files/{file}/view:
    get:
      operationId: getCardFileViewUrl
      tags: [Card Files]
      summary: Get a signed view URL for a card file
      description: Returns a short-lived signed URL that renders the file in the Optiwork Drive viewer.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/fileIdentifier"
      responses:
        "200":
          description: Signed viewer URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, format: uri }
              example: { url: "https://drive.work.optidata.cloud/view/abc123?signature=..." }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/files/{file}:
    delete:
      operationId: deleteCardFile
      tags: [Card Files]
      summary: Delete a card file
      description: |
        Removes the file from the card. Blocked with `422` while the card has
        unsatisfied required custom fields.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/fileIdentifier"
      responses:
        "200":
          description: Confirmation message.
          content:
            application/json:
              schema: { type: string }
              example: File deleted successfully
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ──────────────────────── Card Assignees & Tags ────────────────────────
  /boards/{board}/cards/{card}/assignees/sync:
    post:
      operationId: syncCardAssignees
      tags: [Card Assignees & Tags]
      summary: Attach/detach card assignees
      description: |
        Synchronizes the card's assignees from a map of user id → boolean
        (`true` attaches, `false` detaches). Users not present in the map are
        left unchanged. Newly attached assignees are notified.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assignees:
                  type: [object, "null"]
                  description: Map of user id → attach (true) / detach (false).
                  additionalProperties: { type: boolean }
            example:
              assignees: { "7": true, "8": false }
      responses:
        "200":
          description: The card after the sync.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Card" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/tags/sync:
    post:
      operationId: syncCardTags
      tags: [Card Assignees & Tags]
      summary: Attach/detach card tags
      description: |
        Synchronizes the card's tags from a map of tag id → boolean (`true`
        attaches, `false` detaches). Tags not present in the map are left
        unchanged.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tags:
                  type: [object, "null"]
                  description: Map of tag id → attach (true) / detach (false).
                  additionalProperties: { type: boolean }
            example:
              tags: { "88": true, "97": false }
      responses:
        "200":
          description: The card with its `tags` relation loaded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Card"
                  - type: object
                    properties:
                      tags:
                        type: array
                        items: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ─────────────────────────── Card Recurrence ───────────────────────────
  /boards/{board}/cards/{card}/recurrence:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
    get:
      operationId: getCardRecurrence
      tags: [Card Recurrence]
      summary: Get a card's recurrence rule
      description: Returns the card's active recurrence rule, or `null` when the card does not repeat.
      responses:
        "200":
          description: The recurrence (or null).
          content:
            application/json:
              schema:
                type: object
                properties:
                  recurrence:
                    oneOf:
                      - $ref: "#/components/schemas/CardRecurrence"
                      - type: "null"
              example: { recurrence: null }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: upsertCardRecurrence
      tags: [Card Recurrence]
      summary: Create or update a card's recurrence rule
      description: |
        Defines how often a new occurrence of this card is generated and into
        which column. If the card already has a rule (even a stopped one), it is
        updated; otherwise a new rule is created. iCal-style refinements are
        supported: weekdays (`by_day`), month days (`by_month_day`), months
        (`by_month`) and monthly positions such as "first Monday" (`by_set_pos`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_column_id, frequency]
              properties:
                target_column_id:
                  type: integer
                  description: Column of this board where generated occurrences are placed.
                frequency:
                  type: string
                  enum: [daily, weekly, monthly, yearly]
                interval:
                  type: [integer, "null"]
                  minimum: 1
                  maximum: 99
                  description: Repeat every N periods (default 1).
                by_day:
                  type: [array, "null"]
                  items: { type: string, enum: [SU, MO, TU, WE, TH, FR, SA] }
                  description: Weekdays the rule fires on.
                by_month_day:
                  type: [array, "null"]
                  items: { type: integer, minimum: 1, maximum: 31 }
                  description: Days of the month the rule fires on.
                by_month:
                  type: [array, "null"]
                  items: { type: integer, minimum: 1, maximum: 12 }
                  description: Months the rule fires in (1 = January).
                by_set_pos:
                  type: [array, "null"]
                  items: { type: integer, enum: [1, 2, 3, 4, -1] }
                  description: Position within the month (1 = first ... -1 = last), combined with `by_day`.
            example:
              target_column_id: 301
              frequency: weekly
              interval: 1
              by_day: [MO, TH]
      responses:
        "200":
          description: The stored rule.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  recurrence: { $ref: "#/components/schemas/CardRecurrence" }
              example:
                message: Card repetition created successfully.
                recurrence:
                  id: 15
                  original_card_id: 9001
                  target_column_id: 301
                  frequency: weekly
                  interval: 1
                  by_day: [MO, TH]
                  by_month_day: null
                  by_month: null
                  by_set_pos: null
                  last_occurrence_at: null
                  next_occurrence_at: "2026-07-27T00:00:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: stopCardRecurrence
      tags: [Card Recurrence]
      summary: Stop a card's recurrence
      description: Stops generating new occurrences. Returns `422` when the card has no active recurrence.
      responses:
        "200":
          description: Recurrence stopped.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example: { message: Card repetition stopped successfully. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: The card has no active recurrence.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example: { message: This card does not have an active repetition. }

  # ─────────────────────────────── Comments ───────────────────────────────
  /boards/{board}/cards/{card}/comments:
    post:
      operationId: addComment
      tags: [Comments]
      summary: Add a comment to a card
      description: |
        Adds a comment authored by the caller. Requires `interact` permission
        (comment-create policy). Blocked with `422` while the card has
        unsatisfied required custom fields.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: Comment body (may contain @-mention markup).
            example: { content: "Looks good — ship it 🚀" }
      responses:
        "200":
          description: The created comment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardComment" }
              example:
                id: 7001
                card_id: 9001
                user_id: 7
                content: "Looks good — ship it 🚀"
                created_at: "2026-07-20T14:05:00.000000Z"
                updated_at: "2026-07-20T14:05:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/comments/{comment}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
      - $ref: "#/components/parameters/commentId"
    patch:
      operationId: updateComment
      tags: [Comments]
      summary: Edit a comment
      description: Updates the comment body. Only the author (or a board manager) may edit. New @-mentions are notified.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string, minLength: 1 }
            example: { content: "Looks good — ship it today 🚀" }
      responses:
        "200":
          description: The updated comment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardComment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteComment
      tags: [Comments]
      summary: Delete a comment
      description: Deletes the comment. Only the author (or a board manager) may delete. Blocked with `422` while the card has unsatisfied required custom fields.
      responses:
        "200":
          description: The deleted comment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CardComment" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ───────────────────────────── Time Tracking ─────────────────────────────
  /boards/{board}/cards/{card}/time-tracks/start:
    post:
      operationId: startTimeTracking
      tags: [Time Tracking]
      summary: Start a timer on a card
      description: |
        Starts a running time entry for the caller on this card. Blocked with
        `422` while the card has unsatisfied required custom fields.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      responses:
        "200":
          description: Timer started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example: { message: Time tracking started }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/time-tracks/stop:
    post:
      operationId: stopTimeTracking
      tags: [Time Tracking]
      summary: Stop the running timer on a card
      description: |
        Stops the card's open time entry, computes the tracked seconds and logs
        a "clocked" interaction on the card. Returns `404` when no timer is
        running.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      responses:
        "200":
          description: Timer stopped.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example: { message: Time tracking stopped }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: No running timer on this card (or board/card not found).
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example: { message: Time tracking not found }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/time-tracks/{timeTrack}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
      - $ref: "#/components/parameters/timeTrackId"
    patch:
      operationId: updateTimeTrack
      tags: [Time Tracking]
      summary: Edit a time entry
      description: |
        Adjusts a finished time entry's start/end (and optional description).
        The entry must stay in the past, start before it ends, and must not
        overlap the caller's neighbouring entries on the same card — violations
        return `422`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [start_at, end_at]
              properties:
                start_at: { type: string, format: date-time }
                end_at: { type: string, format: date-time }
                description: { type: [string, "null"], maxLength: 255 }
            example:
              start_at: "2026-07-20T09:00:00Z"
              end_at: "2026-07-20T10:30:00Z"
              description: Homepage wireframes
      responses:
        "200":
          description: The updated entry plus the card's recomputed total.
          content:
            application/json:
              schema:
                type: object
                properties:
                  time_track: { $ref: "#/components/schemas/CardTimeTrack" }
                  total_time_spent:
                    type: string
                    description: Human-readable total tracked time on the card.
              example:
                time_track:
                  id: 610
                  board_id: 42
                  card_id: 9001
                  user_id: 7
                  start_at: "2026-07-20T09:00:00.000000Z"
                  end_at: "2026-07-20T10:30:00.000000Z"
                  amount_seconds: 5400
                  description: Homepage wireframes
                total_time_spent: "1h 30min"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteTimeTrack
      tags: [Time Tracking]
      summary: Delete a time entry
      description: Deletes the time entry and returns the card's recomputed total.
      responses:
        "200":
          description: The deleted entry plus the card's recomputed total.
          content:
            application/json:
              schema:
                type: object
                properties:
                  time_track: { $ref: "#/components/schemas/CardTimeTrack" }
                  total_time_spent: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ────────────────────────────── Checklists ──────────────────────────────
  /boards/{board}/cards/{card}/checklists:
    post:
      operationId: createChecklist
      tags: [Checklists]
      summary: Create a checklist on a card
      description: Adds a titled checklist to the card. All checklist endpoints require `update` permission on the card.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, maxLength: 255 }
            example: { title: Launch checklist }
      responses:
        "201":
          description: The created checklist (with empty items).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Checklist" }
              example:
                id: 120
                card_id: 9001
                title: Launch checklist
                items: []
                items_count: 0
                items_completed_count: 0
                created_at: "2026-07-20T14:00:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/checklists/reorder:
    post:
      operationId: reorderChecklists
      tags: [Checklists]
      summary: Reorder the checklists of a card
      description: Persists the display order of the card's checklists.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [checklist_ids]
              properties:
                checklist_ids:
                  type: array
                  minItems: 1
                  items: { type: integer }
                  description: Checklist ids in the desired order (no duplicates).
            example: { checklist_ids: [121, 120] }
      responses:
        "204": { description: Order saved. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/checklists/items/reorder:
    post:
      operationId: reorderChecklistItems
      tags: [Checklists]
      summary: Reorder checklist items (within and across checklists)
      description: Bulk-positions checklist items; an item may be moved to a different checklist of the same card by sending a new `checklist_id`.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [id, checklist_id, order]
                    properties:
                      id: { type: integer, description: Checklist item id. }
                      checklist_id: { type: integer, description: Target checklist. }
                      order: { type: integer, minimum: 0 }
            example:
              items:
                - { id: 501, checklist_id: 120, order: 0 }
                - { id: 502, checklist_id: 121, order: 0 }
      responses:
        "204": { description: Order saved. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/checklists/{checklist}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
      - $ref: "#/components/parameters/checklistId"
    get:
      operationId: getChecklist
      tags: [Checklists]
      summary: Get a checklist with its items
      description: Returns the checklist with its items and completion counters.
      responses:
        "200":
          description: The checklist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Checklist" }
              example:
                id: 120
                card_id: 9001
                title: Launch checklist
                items:
                  - id: 501
                    checklist_id: 120
                    title: Update DNS records
                    is_completed: false
                    completed_at: null
                    due_date: null
                    due_date_status: null
                    human_due_date: null
                    short_locale_date: null
                    assignees: []
                    created_at: "2026-07-20T14:02:00.000000Z"
                items_count: 1
                items_completed_count: 0
                created_at: "2026-07-20T14:00:00.000000Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateChecklist
      tags: [Checklists]
      summary: Rename a checklist
      description: Updates the checklist title.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, maxLength: 255 }
            example: { title: Go-live checklist }
      responses:
        "200":
          description: The updated checklist.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Checklist" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteChecklist
      tags: [Checklists]
      summary: Delete a checklist
      description: Deletes the checklist and all of its items.
      responses:
        "204": { description: Checklist deleted. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/checklists/{checklist}/items:
    post:
      operationId: createChecklistItem
      tags: [Checklists]
      summary: Add an item to a checklist
      description: Appends an item, optionally with a due date and assignees (map of user id → boolean).
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/checklistId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, maxLength: 255 }
                due_date: { type: [string, "null"], format: date-time }
                assignees:
                  type: [object, "null"]
                  additionalProperties: { type: boolean }
                  description: Map of user id → attach (true) / detach (false).
            example:
              title: Update DNS records
              due_date: "2026-08-01T12:00:00Z"
              assignees: { "8": true }
      responses:
        "201":
          description: The created item.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/checklists/{checklist}/items/{checklistItem}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
      - $ref: "#/components/parameters/checklistId"
      - $ref: "#/components/parameters/checklistItemId"
    get:
      operationId: getChecklistItem
      tags: [Checklists]
      summary: Get a checklist item
      description: Returns a single checklist item.
      responses:
        "200":
          description: The item.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateChecklistItem
      tags: [Checklists]
      summary: Update a checklist item
      description: Updates the item's title, completion state, due date and/or assignees.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 255 }
                is_completed: { type: boolean }
                due_date: { type: [string, "null"], format: date-time }
                assignees:
                  type: [object, "null"]
                  additionalProperties: { type: boolean }
            example: { is_completed: true }
      responses:
        "200":
          description: The updated item.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteChecklistItem
      tags: [Checklists]
      summary: Delete a checklist item
      description: Removes the item from the checklist.
      responses:
        "204": { description: Item deleted. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /boards/{board}/cards/{card}/checklists/{checklist}/items/{checklistItem}/conversion:
    post:
      operationId: convertChecklistItemToCard
      tags: [Checklists]
      summary: Convert a checklist item into a card
      description: |
        Promotes the checklist item to a standalone card on the board and links
        it to the original card with a parent-task relationship. Returns `422`
        when the target is not a checklist item.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/checklistId"
        - $ref: "#/components/parameters/checklistItemId"
      responses:
        "200":
          description: The promoted card and the created relationship.
          content:
            application/json:
              schema:
                type: object
                properties:
                  card: { $ref: "#/components/schemas/Card" }
                  relationship:
                    type: object
                    description: The parent-task relationship created between the original card and the new card.
                    additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/checklists/{checklist}/items/{checklistItem}/assignees/sync:
    post:
      operationId: syncChecklistItemAssignees
      tags: [Checklists]
      summary: Attach/detach assignees on a checklist item
      description: Same contract as card assignee sync — a map of user id → boolean.
      parameters:
        - $ref: "#/components/parameters/boardId"
        - $ref: "#/components/parameters/cardId"
        - $ref: "#/components/parameters/checklistId"
        - $ref: "#/components/parameters/checklistItemId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assignees:
                  type: [object, "null"]
                  additionalProperties: { type: boolean }
            example:
              assignees: { "8": true }
      responses:
        "200":
          description: The item with its assignees.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChecklistItem" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ──────────────────────────── Custom Fields ────────────────────────────
  /boards/{board}/cards/{card}/custom-fields:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
    get:
      operationId: listCardCustomFields
      tags: [Custom Fields]
      summary: List a card's custom field values
      description: Returns the card's stored custom field values (active definitions only), each with its `definition` embedded.
      responses:
        "200":
          description: Custom field values.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CustomFieldValue" }
              example:
                - id: 800
                  card_id: 9001
                  definition_id: 12
                  value_text: High
                  value_integer: null
                  value_decimal: null
                  value_datetime: null
                  value_boolean: null
                  value_json: null
                  definition:
                    id: 12
                    board_id: 42
                    name: Priority
                    type: select
                    description: null
                    config: { options: [{ id: "opt-1", label: High, color: "#ef4444" }, { id: "opt-2", label: Low, color: "#22c55e" }] }
                    order: 0
                    is_required: false
                    is_active: true
                    is_global: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      operationId: bulkUpdateCardCustomFields
      tags: [Custom Fields]
      summary: Set several custom field values on a card
      description: |
        Upserts multiple custom field values in one call. Each `definition_id`
        must be an active definition of this board. Value types are validated
        against each definition's field type (text, number, select option id,
        checkbox boolean, date, person user id).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fields]
              properties:
                fields:
                  type: array
                  items:
                    type: object
                    required: [definition_id]
                    properties:
                      definition_id: { type: integer }
                      value:
                        description: New value; type depends on the definition's field type. Null clears the value.
            example:
              fields:
                - { definition_id: 12, value: opt-1 }
                - { definition_id: 13, value: 8 }
      responses:
        "200":
          description: The updated values.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CustomFieldValue" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/custom-fields/{definition}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
      - $ref: "#/components/parameters/definitionId"
    patch:
      operationId: updateCardCustomField
      tags: [Custom Fields]
      summary: Set one custom field value on a card
      description: Upserts the value of a single custom field. The value type must match the definition's field type; mismatches return `422`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                value:
                  description: New value; type depends on the definition's field type. Null clears the value.
            example: { value: "opt-1" }
      responses:
        "200":
          description: The stored value.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomFieldValue" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: clearCardCustomField
      tags: [Custom Fields]
      summary: Clear a custom field value from a card
      description: Removes the stored value for this definition from the card.
      responses:
        "204": { description: Value cleared. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────── Custom Field Definitions (board) ────────────────────
  /boards/{board}/custom-fields/definitions:
    parameters: [ { $ref: "#/components/parameters/boardId" } ]
    get:
      operationId: listBoardCustomFieldDefinitions
      tags: [Custom Field Definitions]
      summary: List a board's custom field definitions
      description: Returns the board's active custom field definitions in display order. Pass `include_inactive=1` to also return deactivated definitions.
      parameters:
        - name: include_inactive
          in: query
          required: false
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Definitions.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CustomFieldDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      operationId: createBoardCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Create a custom field definition on a board
      description: |
        Defines a new custom field for this board's cards. `name` must be unique
        within the board. `config` is type-specific (e.g. select options, number
        format, text max length) and is validated against the chosen `type`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomFieldDefinitionCreate" }
            example:
              name: Priority
              type: select
              description: Business priority of the task
              is_required: false
              config:
                options:
                  - { label: High, color: "#ef4444" }
                  - { label: Low, color: "#22c55e" }
      responses:
        "201":
          description: The created definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomFieldDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/custom-fields/definitions/reorder:
    patch:
      operationId: reorderBoardCustomFieldDefinitions
      tags: [Custom Field Definitions]
      summary: Reorder a board's custom field definitions
      description: Persists the display order of the board's definitions.
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition_ids]
              properties:
                definition_ids:
                  type: array
                  minItems: 1
                  items: { type: integer }
            example: { definition_ids: [13, 12] }
      responses:
        "204": { description: Order saved. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/custom-fields/definitions/{definition}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/definitionId"
    patch:
      operationId: updateBoardCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Update a custom field definition
      description: |
        Updates the definition's name, description, config, required flag or
        active flag. The field `type` (and `board_id`) cannot be changed —
        sending them returns `422`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomFieldDefinitionUpdate" }
            example: { name: Business priority, is_required: true }
      responses:
        "200":
          description: The updated definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomFieldDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteBoardCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Delete a custom field definition
      description: Soft-deletes the definition; existing stored values stop being shown on cards.
      responses:
        "204": { description: Definition deleted. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ──────────────────── Custom Field Definitions (global) ────────────────────
  /custom-fields/definitions/global:
    get:
      operationId: listGlobalCustomFieldDefinitions
      tags: [Custom Field Definitions]
      summary: List global custom field definitions
      description: Returns tenant-wide (global) definitions that apply to every board. Pass `include_inactive=1` to include deactivated ones.
      parameters:
        - name: include_inactive
          in: query
          required: false
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Global definitions (`board_id` is null, `is_global` true).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/CustomFieldDefinition" }
              example:
                - id: 95
                  board_id: null
                  name: Cost center
                  type: text
                  description: null
                  config: { multiline: false, max_length: 500, placeholder: null, default_value: null }
                  order: 0
                  is_required: false
                  is_active: true
                  is_global: true
                  created_at: "2026-03-18T17:53:13.000000Z"
                  updated_at: "2026-03-31T20:01:55.000000Z"
                  deleted_at: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createGlobalCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Create a global custom field definition
      description: Defines a tenant-wide custom field, available on all boards. `name` must be unique among global definitions. Restricted to administrators.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomFieldDefinitionCreate" }
            example:
              name: Cost center
              type: text
              config: { multiline: false, max_length: 500 }
      responses:
        "201":
          description: The created definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomFieldDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /custom-fields/definitions/global/reorder:
    patch:
      operationId: reorderGlobalCustomFieldDefinitions
      tags: [Custom Field Definitions]
      summary: Reorder global custom field definitions
      description: Persists the display order of the tenant's global definitions. Restricted to administrators.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [definition_ids]
              properties:
                definition_ids:
                  type: array
                  minItems: 1
                  items: { type: integer }
            example: { definition_ids: [152, 95] }
      responses:
        "204": { description: Order saved. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /custom-fields/definitions/global/{definition}:
    parameters:
      - $ref: "#/components/parameters/definitionId"
    patch:
      operationId: updateGlobalCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Update a global custom field definition
      description: Same contract as the board-scoped update — `type` and `board_id` cannot be changed. Restricted to administrators.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CustomFieldDefinitionUpdate" }
            example: { is_active: false }
      responses:
        "200":
          description: The updated definition.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CustomFieldDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteGlobalCustomFieldDefinition
      tags: [Custom Field Definitions]
      summary: Delete a global custom field definition
      description: Soft-deletes the global definition across all boards. Restricted to administrators.
      responses:
        "204": { description: Definition deleted. No content. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─────────────────────────────── Tags ───────────────────────────────
  /boards/{board}/tags:
    post:
      operationId: createBoardTag
      tags: [Tags]
      summary: Create a board tag
      description: Creates a tag scoped to this board. The response also includes a rendered HTML snippet used by the Optiwork web client (safe to ignore in API integrations).
      parameters: [ { $ref: "#/components/parameters/boardId" } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
            example: { name: frontend, color: "#52eb00", description: Front-end work }
      responses:
        "200":
          description: The created tag.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TagEnvelope" }
              example:
                tag: { id: 88, board_id: 42, name: frontend, description: Front-end work, color: "#52eb00", created_at: "2026-07-20T14:00:00.000000Z", updated_at: "2026-07-20T14:00:00.000000Z" }
                tag_list_item_html: "<li>…</li>"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/tags/{tag}:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/tagId"
    get:
      operationId: getBoardTag
      tags: [Tags]
      summary: Get a board tag
      description: Returns a single board-scoped tag.
      responses:
        "200":
          description: The tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateBoardTag
      tags: [Tags]
      summary: Update a board tag
      description: Updates the tag's name, color and/or description.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
            example: { name: front-end, color: "#22c55e" }
      responses:
        "200":
          description: The updated tag.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TagEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteBoardTag
      tags: [Tags]
      summary: Delete a board tag
      description: Deletes the tag and detaches it from every card of the board.
      responses:
        "200":
          description: The deleted tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /tags/common:
    post:
      operationId: createCommonTag
      tags: [Tags]
      summary: Create a common (tenant-wide) tag
      description: Creates a tag with no board scope — usable on every board of the tenant.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
            example: { name: urgent, color: "#ef4444" }
      responses:
        "200":
          description: The created tag (`board_id` null).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TagEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /tags/common/{tag}:
    parameters:
      - $ref: "#/components/parameters/tagId"
    get:
      operationId: getCommonTag
      tags: [Tags]
      summary: Get a common tag
      description: Returns a single common tag.
      responses:
        "200":
          description: The tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateCommonTag
      tags: [Tags]
      summary: Update a common tag
      description: Updates the common tag's name, color and/or description.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TagWrite" }
            example: { name: urgent, color: "#f97316" }
      responses:
        "200":
          description: The updated tag.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TagEnvelope" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
    delete:
      operationId: deleteCommonTag
      tags: [Tags]
      summary: Delete a common tag
      description: Deletes the common tag and detaches it from every card across all boards.
      responses:
        "200":
          description: The deleted tag.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tag: { $ref: "#/components/schemas/Tag" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ─────────────────────────── Contact Links ───────────────────────────
  /boards/{board}/link/contacts:
    parameters: [ { $ref: "#/components/parameters/boardId" } ]
    get:
      operationId: listBoardContactLinks
      tags: [Contact Links]
      summary: List contacts linked to a board
      description: Returns the Optiwork Contacts records linked to this board, enriched with contact data fetched from the Contacts app.
      responses:
        "200":
          description: Linked contacts (empty array when none).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  description: Contact record as provided by the Optiwork Contacts app.
                  additionalProperties: true
              example:
                - { id: 3301, name: Acme Procurement, email: procurement@acme.example }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateBoardContactLinks
      tags: [Contact Links]
      summary: Link/unlink contacts on a board
      description: Adds and removes links to Optiwork Contacts records in one atomic call.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactLinkUpdate" }
            example:
              external_modelable_ids_to_add: [3301]
              external_modelable_ids_to_remove: [3105]
      responses:
        "200":
          description: Counts of links changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactLinkSyncResult" }
              example: { added_count: 1, removed_count: 1 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  /boards/{board}/cards/{card}/link/contacts:
    parameters:
      - $ref: "#/components/parameters/boardId"
      - $ref: "#/components/parameters/cardId"
    get:
      operationId: listCardContactLinks
      tags: [Contact Links]
      summary: List contacts linked to a card
      description: Returns the Optiwork Contacts records linked to this card.
      responses:
        "200":
          description: Linked contacts (empty array when none).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateCardContactLinks
      tags: [Contact Links]
      summary: Link/unlink contacts on a card
      description: Adds and removes links to Optiwork Contacts records on the card in one atomic call.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactLinkUpdate" }
            example:
              external_modelable_ids_to_add: [3301]
      responses:
        "200":
          description: Counts of links changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactLinkSyncResult" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

  # ─────────────────────────────── Dashboard ───────────────────────────────
  /users/{user}/dashboard:
    get:
      operationId: getUserDashboard
      tags: [Dashboard]
      summary: Get a user's upcoming tasks
      description: |
        Returns the user's cards due within the next 3 days across all active
        boards they belong to, ordered by nearest due date. Each card includes a
        deep `link` into the Optiwork web app. The user is addressed by UUID.
      parameters:
        - name: user
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The user's UUID.
        - name: limit
          in: query
          required: true
          schema: { type: integer, minimum: 1, maximum: 100 }
          description: Maximum number of cards to return.
      responses:
        "200":
          description: Upcoming cards.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: integer }
                    title: { type: string }
                    due_date: { type: string, format: date-time }
                    completed_at: { type: [string, "null"], format: date-time }
                    board_id: { type: integer }
                    column_id: { type: integer }
                    board:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                    assignees:
                      type: array
                      items: { $ref: "#/components/schemas/UserSlim" }
                    link:
                      type: string
                      format: uri
                      description: Deep link to open the card in the Optiwork web app.
              example:
                - id: 9001
                  title: Draft homepage copy
                  due_date: "2026-07-24T12:00:00.000000Z"
                  completed_at: null
                  board_id: 42
                  column_id: 301
                  board: { id: 42, name: Website Redesign }
                  assignees: []
                  link: "https://projects.work.optidata.cloud/urn/acme/UkV..."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }

# ═══════════════════════════════ Components ═══════════════════════════════
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Optiwork **passport** — the JWT shown as the **authentication key** at
        `https://work.optidata.cloud/user/settings/security`
        (My account > Profile > Security). Send it as
        `Authorization: Bearer <token>`. Passports expire; on `401`, get a
        fresh authentication key from that page and retry.

  parameters:
    boardId:
      name: board
      in: path
      required: true
      schema: { type: integer }
      description: Board id.
    columnId:
      name: column
      in: path
      required: true
      schema: { type: integer }
      description: Column id (must belong to the board).
    cardId:
      name: card
      in: path
      required: true
      schema: { type: integer }
      description: Card id (must belong to the board).
    commentId:
      name: comment
      in: path
      required: true
      schema: { type: integer }
      description: Comment id (must belong to the card).
    timeTrackId:
      name: timeTrack
      in: path
      required: true
      schema: { type: integer }
      description: Time entry id (must belong to the card).
    checklistId:
      name: checklist
      in: path
      required: true
      schema: { type: integer }
      description: Checklist id (must belong to the card).
    checklistItemId:
      name: checklistItem
      in: path
      required: true
      schema: { type: integer }
      description: Checklist item id (must belong to the checklist).
    definitionId:
      name: definition
      in: path
      required: true
      schema: { type: integer }
      description: Custom field definition id.
    tagId:
      name: tag
      in: path
      required: true
      schema: { type: integer }
      description: Tag id.
    fileIdentifier:
      name: file
      in: path
      required: true
      schema: { type: string }
      description: File **identifier** (opaque string, not the numeric id).

  responses:
    Unauthorized:
      description: Missing, invalid or expired bearer passport.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
          example: { error: "JWT passport exception: Missing bearer token in request" }
    Forbidden:
      description: The authenticated user lacks the required board permission.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { message: This action is unauthorized. }
    NotFound:
      description: Resource not found, or a nested resource does not belong to its parent in the path.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { message: Not found. }
    ValidationError:
      description: Validation failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ValidationError" }
          example:
            message: The name field is required.
            errors:
              name: ["The name field is required."]

  schemas:
    Error:
      type: object
      properties:
        message: { type: string }

    ValidationError:
      type: object
      properties:
        message:
          type: string
          description: First validation error message.
        errors:
          type: object
          description: Map of field name → list of error messages.
          additionalProperties:
            type: array
            items: { type: string }

    IdBatch:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          items: { type: integer }
          description: Ids to operate on.

    Board:
      type: object
      description: A project (kanban board). Boards nest via `parent_id`.
      properties:
        id: { type: integer }
        user_id: { type: [integer, "null"], description: Owner user id. }
        group_id: { type: [integer, "null"] }
        cuid:
          type: string
          description: Tenant/company identifier the board belongs to.
        parent_id: { type: [integer, "null"], description: Parent board id (null for root boards). }
        name: { type: string, maxLength: 80 }
        description: { type: [string, "null"], maxLength: 600 }
        icon: { type: [string, "null"], description: Emoji icon. }
        visibility: { type: string, enum: [private, public] }
        order_cards_by:
          type: [string, "null"]
          description: Default card sort field for new columns.
        order_cards_direction:
          type: [string, "null"]
          enum: [asc, desc, null]
        archived_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    BoardDetail:
      allOf:
        - $ref: "#/components/schemas/Board"
        - type: object
          description: Board with its full working set, as returned by `GET /boards/{board}`.
          properties:
            members:
              type: object
              description: Map of user id → member (with `pivot` membership info).
              additionalProperties: { $ref: "#/components/schemas/BoardMemberUser" }
            tags:
              type: object
              description: Map of tag id → tag (board tags plus common tags).
              additionalProperties: { $ref: "#/components/schemas/Tag" }
            columns:
              type: object
              description: Map of column id → column.
              additionalProperties: { $ref: "#/components/schemas/Column" }
            cards:
              type: array
              description: The board's cards (listing attributes).
              items: { $ref: "#/components/schemas/Card" }
            cards_summary:
              type: object
              description: Per-card counters (present on search responses).
              additionalProperties: true
            custom_field_definitions:
              type: array
              items: { $ref: "#/components/schemas/CustomFieldDefinition" }
            permissions:
              type: object
              description: The caller's effective abilities on this board (ability → boolean).
              additionalProperties: { type: boolean }
            preferences:
              type: object
              description: The caller's per-board preferences.
              properties:
                view_mode: { type: string, enum: [view_kanban, view_list] }
                notification:
                  type: object
                  properties:
                    notification_mode: { type: string, enum: [all, assignee, mentions, silent] }
                    is_notified_through_app: { type: boolean }
                    is_notified_through_email: { type: boolean }

    BoardTreeNode:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        icon: { type: [string, "null"] }
        children:
          type: array
          items: { $ref: "#/components/schemas/BoardTreeNode" }

    Column:
      type: object
      description: A task list within a board.
      properties:
        id: { type: integer }
        board_id: { type: integer }
        name: { type: string, maxLength: 80 }
        description: { type: [string, "null"], maxLength: 300 }
        icon: { type: [string, "null"], maxLength: 255 }
        color:
          type: [string, "null"]
          enum: ["#ef4444", "#f97316", "#f59e0b", "#84cc16", "#10b981", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#64748b", null]
          description: Column accent color (fixed palette).
        order: { type: integer, description: Position within the board. }
        sort_order: { type: [string, "null"], description: "Sticky automatic card sort applied to this column, when set." }
        archived_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    ColumnWrite:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 80 }
        description: { type: [string, "null"], maxLength: 300 }
        icon: { type: [string, "null"], maxLength: 255 }
        color:
          type: [string, "null"]
          enum: ["#ef4444", "#f97316", "#f59e0b", "#84cc16", "#10b981", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#64748b", null]

    Card:
      type: object
      description: |
        A task. Derived attributes (`tags_ids`, `assignees_ids`, `*_count`,
        `human_*`, `due_date_status`, `is_time_tracking`) are present on single
        card reads; list contexts may return a subset of attributes.
      properties:
        id: { type: integer }
        board_id: { type: integer }
        column_id: { type: integer }
        user_id: { type: [integer, "null"], description: Creator user id. }
        title: { type: string, maxLength: 255 }
        content:
          type: [string, "null"]
          description: Rich-text description (serialized editor document, JSON string).
        type: { type: string, enum: [ORDINARY, CHECKLIST_ITEM] }
        status: { type: [string, "null"], enum: [OPEN, CLOSED, ARCHIVED, null] }
        icon: { type: [string, "null"] }
        order: { type: integer, description: Position within its column. }
        number: { type: integer, description: Sequential card number within the board. }
        due_date: { type: [string, "null"], format: date-time }
        due_date_hash: { type: [string, "null"], description: Opaque id tied to the current due date (used for calendar sync). }
        pinned_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        archived_at: { type: [string, "null"], format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }
        checklist_id: { type: [integer, "null"], description: Owning checklist (checklist-item cards only). }
        checklist_items: { type: [integer, "null"], description: Cached count of checklist items on this card. }
        checklist_items_completed: { type: [integer, "null"] }
        card_recurrence_id: { type: [integer, "null"] }
        cover_color:
          type: [string, "null"]
          enum: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280", "#000000", null]
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        tags_ids:
          type: [array, "null"]
          items: { type: [string, "null"] }
          description: Ids of the card's tags (as strings; may contain null when empty).
        assignees_ids:
          type: [array, "null"]
          items: { type: [string, "null"] }
          description: Ids of the card's assignees (as strings; may contain null when empty).
        is_time_tracking:
          type: [boolean, integer, "null"]
          description: Truthy when the caller has a running timer on this card.
        comments_count: { type: integer }
        files_count: { type: integer }
        custom_fields_count: { type: integer }
        due_date_status: { type: [string, "null"], enum: [OVERDUE, NEAR, FAR, null] }
        human_due_date: { type: [string, "null"], description: "Localized short due date (e.g. Aug 1, 2026)." }
        short_locale_date: { type: [string, "null"] }
        human_verbose_due_date: { type: [string, "null"] }
        is_completed: { type: boolean }
        is_recurrent: { type: boolean }

    CardSlim:
      type: object
      properties:
        id: { type: integer }
        column_id: { type: integer }
        title: { type: string }
        icon: { type: [string, "null"] }
        number: { type: integer }

    Tag:
      type: object
      description: A label. `board_id` null means a common (tenant-wide) tag.
      properties:
        id: { type: integer }
        board_id: { type: [integer, "null"] }
        name: { type: string, minLength: 2, maxLength: 40 }
        description: { type: [string, "null"], minLength: 3, maxLength: 250 }
        color: { type: string, description: "Hex color (e.g. #52eb00)." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TagWrite:
      type: object
      required: [name, color]
      properties:
        name: { type: string, minLength: 2, maxLength: 40 }
        color: { type: string, description: Hex color. }
        description: { type: [string, "null"], minLength: 3, maxLength: 250 }

    TagEnvelope:
      type: object
      properties:
        tag: { $ref: "#/components/schemas/Tag" }
        tag_list_item_html:
          type: string
          description: Server-rendered HTML snippet for the Optiwork web client. Safe to ignore.

    UserSlim:
      type: object
      properties:
        id: { type: integer }
        uuid: { type: string, format: uuid }
        name: { type: string }
        email: { type: string, format: email }
        avatar_url: { type: [string, "null"], format: uri }

    BoardMemberUser:
      allOf:
        - $ref: "#/components/schemas/UserSlim"
        - type: object
          properties:
            username: { type: string }
            company_name: { type: [string, "null"] }
            role: { type: [string, "null"] }
            status: { type: [string, "null"] }
            deleted_at: { type: [string, "null"], format: date-time }
            pivot:
              type: object
              description: Membership metadata.
              properties:
                board_id: { type: integer }
                user_id: { type: integer }
                permission: { type: string, enum: [view, interact, edit, manage] }
                view_mode: { type: [string, "null"], enum: [view_kanban, view_list, null] }
                created_at: { type: string, format: date-time }
                updated_at: { type: string, format: date-time }

    CardComment:
      type: object
      properties:
        id: { type: integer }
        card_id: { type: integer }
        user_id: { type: integer, description: Author user id. }
        content: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CardTimeTrack:
      type: object
      description: A time entry on a card. `end_at` null means the timer is running.
      properties:
        id: { type: integer }
        board_id: { type: integer }
        card_id: { type: integer }
        user_id: { type: integer }
        start_at: { type: string, format: date-time }
        end_at: { type: [string, "null"], format: date-time }
        amount_seconds: { type: [integer, "null"], description: Tracked duration in seconds (set when stopped). }
        description: { type: [string, "null"], maxLength: 255 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Checklist:
      type: object
      properties:
        id: { type: integer }
        card_id: { type: integer }
        title: { type: string, maxLength: 255 }
        items:
          type: array
          items: { $ref: "#/components/schemas/ChecklistItem" }
        items_count: { type: integer }
        items_completed_count: { type: integer }
        created_at: { type: string, format: date-time }

    ChecklistItem:
      type: object
      properties:
        id: { type: integer }
        checklist_id: { type: integer }
        title: { type: string, maxLength: 255 }
        is_completed: { type: boolean }
        completed_at: { type: [string, "null"], format: date-time }
        due_date: { type: [string, "null"], format: date-time }
        due_date_status: { type: [string, "null"], enum: [OVERDUE, NEAR, FAR, null] }
        human_due_date: { type: [string, "null"] }
        short_locale_date: { type: [string, "null"] }
        assignees:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              uuid: { type: string, format: uuid }
              name: { type: string }
              avatar_url: { type: [string, "null"], format: uri }
        created_at: { type: string, format: date-time }

    CardRecurrence:
      type: object
      description: Repeat rule for a card.
      properties:
        id: { type: integer }
        original_card_id: { type: integer }
        target_column_id: { type: integer }
        frequency: { type: string, enum: [daily, weekly, monthly, yearly] }
        interval: { type: [integer, "null"], minimum: 1, maximum: 99 }
        by_day:
          type: [array, "null"]
          items: { type: string, enum: [SU, MO, TU, WE, TH, FR, SA] }
        by_month_day:
          type: [array, "null"]
          items: { type: integer }
        by_month:
          type: [array, "null"]
          items: { type: integer, minimum: 1, maximum: 12 }
        by_set_pos:
          type: [array, "null"]
          items: { type: integer, enum: [1, 2, 3, 4, -1] }
        last_occurrence_at: { type: [string, "null"], format: date-time }
        next_occurrence_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    CustomFieldDefinition:
      type: object
      description: Definition of a custom field. `board_id` null (and `is_global` true) means tenant-wide.
      properties:
        id: { type: integer }
        board_id: { type: [integer, "null"] }
        name: { type: string, minLength: 1, maxLength: 60 }
        type: { type: string, enum: [text, number, select, checkbox, date, person] }
        description: { type: [string, "null"], maxLength: 250 }
        config:
          type: [object, "null"]
          description: |
            Type-specific configuration. Examples — text: `{multiline, max_length, placeholder, default_value}`;
            select: `{options: [{id, label, color}]}`; checkbox: `{default_value}`.
          additionalProperties: true
        order: { type: integer }
        is_required: { type: boolean, description: "Required fields block card interactions (comments, files, timers) until filled." }
        is_active: { type: boolean }
        is_global: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    CustomFieldDefinitionCreate:
      type: object
      required: [name, type]
      properties:
        name: { type: string, minLength: 1, maxLength: 60, description: Unique within the board (or among global definitions). }
        type: { type: string, enum: [text, number, select, checkbox, date, person] }
        description: { type: [string, "null"], maxLength: 250 }
        config:
          type: [object, "null"]
          description: Type-specific configuration; validated against the chosen `type`.
          additionalProperties: true
        is_required: { type: boolean, default: false }

    CustomFieldDefinitionUpdate:
      type: object
      description: Partial update. `type` and `board_id` are immutable (sending them fails validation).
      properties:
        name: { type: string, minLength: 1, maxLength: 60 }
        description: { type: [string, "null"], maxLength: 250 }
        config:
          type: [object, "null"]
          additionalProperties: true
        is_required: { type: boolean }
        is_active: { type: boolean }

    CustomFieldValue:
      type: object
      description: A stored custom field value. Exactly one `value_*` column is populated, matching the definition's type.
      properties:
        id: { type: integer }
        card_id: { type: integer }
        definition_id: { type: integer }
        value_text: { type: [string, "null"] }
        value_integer: { type: [integer, "null"] }
        value_decimal: { type: [number, "null"] }
        value_datetime: { type: [string, "null"], format: date-time }
        value_boolean: { type: [boolean, "null"] }
        value_json: {}
        definition: { $ref: "#/components/schemas/CustomFieldDefinition" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ContactLinkUpdate:
      type: object
      properties:
        external_modelable_ids_to_add:
          type: array
          items: { type: integer }
          description: Optiwork Contacts record ids to link.
        external_modelable_ids_to_remove:
          type: array
          items: { type: integer }
          description: Optiwork Contacts record ids to unlink.

    ContactLinkSyncResult:
      type: object
      properties:
        added_count: { type: integer }
        removed_count: { type: integer }

    FileUploadResult:
      type: object
      properties:
        file:
          type: object
          properties:
            id: { type: integer }
            identifier: { type: string, description: Opaque file identifier used in file URLs. }
            download_url: { type: string, format: uri }
            content_url: { type: string, format: uri, description: Inline-content URL (renders in the editor). }
            view_url: { type: [string, "null"], format: uri, description: Signed Optiwork Drive viewer URL. }
