openapi: 3.0.3
info:
  title: Easerix Tasks API
  description: |
    Linear-style issue tracker (backend/services/products/tasks): teams,
    workflow states, projects, issues, labels, cycles, comments, activity
    history, notifications, and file attachments.

    Tenancy: teams are the tenant root (docs/accounts/TENANCY.md) — every
    other row hangs off team_id, and every query is scoped to the teams of
    the caller's active org. Teams, issues, and anything else outside the
    caller's workspace read as 404, never 403. A fresh org is bootstrapped
    lazily on first request: a "General" (GEN) team with the default
    five-state workflow (Backlog, Todo, In Progress, Done, Canceled), and a
    members row is upserted from the JWT.

    Timestamps are RFC3339 under `created` / `modified` (not createdAt).
    Responds in camelCase (same recorded drift as links/notes — see
    contracts/README.md).

    PATCH semantics: only the fields present in the body change, and an empty
    body is a legal no-op. A body that does not parse as JSON — or that sends a
    field with the wrong type — is a 400; it is never treated as "no fields
    sent", so a request that asked for something never answers 200 without
    having applied it.

    Names are unique per TEAM, not per org: states, projects, labels, and named
    cycles are compared trimmed and case-insensitively within their team, and a
    collision is a 409 on both create and rename. Two teams in one workspace may
    each hold a "Bug" label or a "Done" state.

    Teams themselves are the exception, being the tenant root: a team's name and
    its key are unique per WORKSPACE. Two workspaces may each hold an
    "Engineering" (ENG); one workspace may not hold two.
  version: 1.0.0
  contact:
    name: Perizer Labs
    url: https://easerix.com
servers:
  - url: https://api.easerix.com/tasks
    description: Production
tags:
  - name: system
    description: Health and liveness
  - name: members
    description: The org's member directory (upserted from JWTs)
  - name: teams
    description: Teams — the tenant root
  - name: states
    description: Per-team workflow states
  - name: projects
    description: Per-team projects
  - name: issues
    description: Issues, sub-issues, and label assignment
  - name: labels
    description: Per-team labels
  - name: cycles
    description: Per-team cycles (sprints)
  - name: comments
    description: Issue comments (author-only editing)
  - name: activity
    description: Per-issue property-change history
  - name: attachments
    description: File uploads (R2) and public presigned fetch

security:
  - bearer: []

paths:
  /health:
    get:
      operationId: health
      tags: [system]
      summary: Liveness probe
      description: Unauthenticated liveness check used by the platform and monitors.
      security: []
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema:
                type: object

  /docs:
    get:
      operationId: apiReference
      tags: [system]
      summary: API reference UI
      description: >-
        Unauthenticated, unlisted Scalar API reference for the running
        instance — for local/internal use while poking at the API, not
        linked from any product surface.
      security: []
      responses:
        "200":
          description: The reference UI
          content:
            text/html:
              schema:
                type: string

  /docs/openapi.yaml:
    get:
      operationId: openAPISpec
      tags: [system]
      summary: This contract, served by the running instance
      description: >-
        The embedded copy of this OpenAPI document — backs the reference UI
        above and is handy for local codegen against a running instance.
      security: []
      responses:
        "200":
          description: The OpenAPI document
          content:
            text/plain:
              schema:
                type: string

  /v1/members:
    get:
      operationId: listMembers
      tags: [members]
      summary: List the org's members
      description: >-
        Members of the caller's active org (upserted from JWTs on each
        request), ordered by name then email — used for assignee and lead
        pickers.
      responses:
        "200":
          description: Members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: "#/components/schemas/Member"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"

  /v1/teams:
    get:
      operationId: listTeams
      tags: [teams]
      summary: List the org's teams
      description: >-
        Teams of the caller's active org, ordered by key. Never empty for a
        used workspace — the first request bootstraps a "General" (GEN) team.
      responses:
        "200":
          description: Teams
          content:
            application/json:
              schema:
                type: object
                required: [teams]
                properties:
                  teams:
                    type: array
                    items:
                      $ref: "#/components/schemas/Team"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createTeam
      tags: [teams]
      summary: Create a team
      description: >-
        Creates a team in the caller's org, seeded with the default workflow
        states (Backlog, Todo, In Progress, Done, Canceled) so it can hold
        issues immediately.

        Key and name are stored trimmed, and both are unique per WORKSPACE —
        not per team like the names below, since teams are the tenant root. A
        key or a name another team in the org already holds is a 409.

        The two comparisons differ: names are compared trimmed and
        case-insensitively (one workspace cannot hold "Design" and "design",
        though two workspaces may each hold their own), while the key
        comparison is exact, so "eng" and "ENG" are currently two different
        keys in one workspace.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key, name]
              properties:
                key:
                  type: string
                  description: Short issue-key prefix (e.g. "GEN").
                name:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [team]
                properties:
                  team:
                    $ref: "#/components/schemas/Team"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/states:
    get:
      operationId: listStates
      tags: [states]
      summary: List workflow states
      description: >-
        Workflow states across the org's teams, ordered by position. Filter
        with team_id (a team outside the org just yields an empty list).
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: States
          content:
            application/json:
              schema:
                type: object
                required: [states]
                properties:
                  states:
                    type: array
                    items:
                      $ref: "#/components/schemas/State"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createState
      tags: [states]
      summary: Create a workflow state
      description: >-
        Color defaults to "#95a2b3"; position defaults to (max position in the
        team) + 1. The team must belong to the caller's org (else 404 — org
        tenancy). `name` is stored trimmed and must be unique within the team,
        compared case-insensitively — a name already in use is a 409. Two
        different teams may each use the same name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name, type]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                type:
                  type: string
                  enum: [backlog, unstarted, started, completed, canceled]
                color:
                  type: string
                position:
                  type: integer
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [state]
                properties:
                  state:
                    $ref: "#/components/schemas/State"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/states/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      operationId: updateState
      tags: [states]
      summary: Update a workflow state
      description: >-
        Partial update — only provided fields change. A `name` that another state
        in the same team already uses is a 409; a blank one is a 400. Re-sending
        the state's own name is always allowed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                type:
                  type: string
                  enum: [backlog, unstarted, started, completed, canceled]
                color:
                  type: string
                position:
                  type: integer
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [state]
                properties:
                  state:
                    $ref: "#/components/schemas/State"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteState
      tags: [states]
      summary: Delete a workflow state
      description: Hard delete. Issues referencing the state are not reassigned.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/projects:
    get:
      operationId: listProjects
      tags: [projects]
      summary: List projects
      description: Projects across the org's teams, newest first. Filter with team_id.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: Projects
          content:
            application/json:
              schema:
                type: object
                required: [projects]
                properties:
                  projects:
                    type: array
                    items:
                      $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createProject
      tags: [projects]
      summary: Create a project
      description: >-
        Status defaults to "planned". leadId must reference a member of the
        caller's org (else 400 "invalid leadId"); an empty-string leadId is
        treated as null. targetDate accepts YYYY-MM-DD (or RFC3339, truncated
        to the day). The team must belong to the caller's org (else 404). `name`
        is stored trimmed and must be unique within the team, compared
        case-insensitively — a name already in use is a 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                description:
                  type: string
                status:
                  type: string
                  enum: [planned, in_progress, paused, completed, canceled]
                leadId:
                  type: string
                  format: uuid
                  nullable: true
                targetDate:
                  type: string
                  format: date
                  nullable: true
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/projects/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: getProject
      tags: [projects]
      summary: Get a project
      description: One project (404 when it belongs to another org).
      responses:
        "200":
          description: The project
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateProject
      tags: [projects]
      summary: Update a project
      description: >-
        Partial update. Setting leadId to "" clears the lead; a non-empty
        leadId outside the org's members is 400 "invalid leadId". A `name` that
        another project in the same team already uses is a 409; a blank one is a
        400. Re-sending the project's own name is always allowed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                description:
                  type: string
                status:
                  type: string
                  enum: [planned, in_progress, paused, completed, canceled]
                leadId:
                  type: string
                  nullable: true
                  description: Empty string clears the lead.
                targetDate:
                  type: string
                  format: date
                  nullable: true
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [project]
                properties:
                  project:
                    $ref: "#/components/schemas/Project"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteProject
      tags: [projects]
      summary: Delete a project
      description: Hard delete. Issues keep their projectId reference.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues:
    get:
      operationId: listIssues
      tags: [issues]
      summary: List issues
      description: >-
        Issues across the org's teams, ordered by position then number,
        capped at 1000 rows (no pagination — the cap is the contract).
        Filters combine with AND; q is a case-insensitive title substring
        match (LIKE wildcards in q are escaped and match literally).
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
        - name: project_id
          in: query
          schema:
            type: string
            format: uuid
        - name: state_id
          in: query
          schema:
            type: string
            format: uuid
        - name: assignee_id
          in: query
          schema:
            type: string
            format: uuid
        - name: cycle_id
          in: query
          schema:
            type: string
            format: uuid
        - name: parent_id
          in: query
          schema:
            type: string
            format: uuid
        - name: q
          in: query
          description: Case-insensitive title substring.
          schema:
            type: string
      responses:
        "200":
          description: Issues (at most 1000)
          content:
            application/json:
              schema:
                type: object
                required: [issues]
                properties:
                  issues:
                    type: array
                    maxItems: 1000
                    items:
                      $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createIssue
      tags: [issues]
      summary: Create an issue
      description: >-
        The issue number is per-team incrementing (max(number)+1, computed in
        a transaction) and position starts equal to the number. State
        resolution: an explicit stateId must belong to the target team (else
        400 "stateId does not belong to that team"); omitted, the team's
        lowest-position state is used; a team with no states at all is 400
        ("no workflow state available; create a state first"). priority
        defaults to 0 (0 none, 1 urgent, 2 high, 3 medium, 4 low); estimate 0
        means "no estimate" and is stored as null. assigneeId must be an org
        member; projectId/cycleId/parentId must reference rows inside the
        caller's org (else 400 "invalid …Id"). Records a "created" activity
        and notifies the assignee (import-mode creates do not notify).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, title]
              properties:
                teamId:
                  type: string
                  format: uuid
                projectId:
                  type: string
                  format: uuid
                  nullable: true
                title:
                  type: string
                description:
                  type: string
                stateId:
                  type: string
                  format: uuid
                  nullable: true
                priority:
                  type: integer
                  description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
                assigneeId:
                  type: string
                  format: uuid
                  nullable: true
                parentId:
                  type: string
                  format: uuid
                  nullable: true
                cycleId:
                  type: string
                  format: uuid
                  nullable: true
                estimate:
                  type: integer
                  nullable: true
                  description: 0 means "no estimate" (stored as null).
                dueDate:
                  type: string
                  format: date
                  nullable: true
                  description: YYYY-MM-DD; a malformed value is 400.
                createdAt:
                  type: string
                  format: date-time
                  description: >-
                    Import mode (org owner/admin only, else 403): historical
                    creation timestamp preserved from a source tracker. Also
                    sets updatedAt. Imported issues do not notify.
                completedAt:
                  type: string
                  format: date-time
                  description: Import mode (org owner/admin only, else 403).
                creatorEmail:
                  type: string
                  format: email
                  description: >-
                    Import mode (org owner/admin only, else 403): attribute the
                    issue to the org member with this email; unknown emails
                    fall back to the caller.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: getIssue
      tags: [issues]
      summary: Get an issue
      description: The issue with state, assignee, and labels embedded.
      responses:
        "200":
          description: The issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updateIssue
      tags: [issues]
      summary: Update an issue
      description: >-
        Partial update. Empty string clears assigneeId / projectId / cycleId /
        parentId; estimate 0 clears the estimate; a malformed dueDate is 400
        (never treated as "clear"). stateId must belong to the issue's team
        (else 400 "stateId does not belong to this issue's team"); moving to a
        completed-type state sets completedAt, moving anywhere else clears it.
        Property changes are recorded as activity entries, and an assignee
        change notifies the new assignee.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                description:
                  type: string
                stateId:
                  type: string
                  format: uuid
                priority:
                  type: integer
                  description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
                assigneeId:
                  type: string
                  nullable: true
                  description: Empty string clears the assignee.
                projectId:
                  type: string
                  nullable: true
                  description: Empty string clears the project.
                parentId:
                  type: string
                  nullable: true
                  description: Empty string clears the parent.
                cycleId:
                  type: string
                  nullable: true
                  description: Empty string clears the cycle.
                estimate:
                  type: integer
                  nullable: true
                  description: 0 clears the estimate.
                position:
                  type: number
                dueDate:
                  type: string
                  format: date
                  nullable: true
                  description: YYYY-MM-DD; a malformed value is 400.
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteIssue
      tags: [issues]
      summary: Delete an issue
      description: Hard delete of the issue row.
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/labels:
    post:
      operationId: addIssueLabel
      tags: [issues]
      summary: Add a label to an issue
      description: >-
        Idempotent (re-adding is a no-op). Both the issue and the label must
        be inside the caller's org (else 404). Returns 200 (not 201) with the
        full updated issue.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [labelId]
              properties:
                labelId:
                  type: string
                  format: uuid
      responses:
        "200":
          description: The updated issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/labels/{labelId}:
    delete:
      operationId: removeIssueLabel
      tags: [issues]
      summary: Remove a label from an issue
      description: Returns the full updated issue (removing an absent label is a no-op).
      parameters:
        - $ref: "#/components/parameters/Id"
        - name: labelId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The updated issue
          content:
            application/json:
              schema:
                type: object
                required: [issue]
                properties:
                  issue:
                    $ref: "#/components/schemas/Issue"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/comments:
    parameters:
      - $ref: "#/components/parameters/Id"
    get:
      operationId: listComments
      tags: [comments]
      summary: List an issue's comments
      description: Oldest first, with the author embedded for direct rendering.
      responses:
        "200":
          description: Comments
          content:
            application/json:
              schema:
                type: object
                required: [comments]
                properties:
                  comments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Comment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      operationId: createComment
      tags: [comments]
      summary: Comment on an issue
      description: >-
        Notifies the issue's creator and assignee (minus the author). Imported
        comments (createdAt/authorEmail set) do not notify.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                createdAt:
                  type: string
                  format: date-time
                  description: >-
                    Import mode (org owner/admin only, else 403): historical
                    timestamp preserved from a source tracker.
                authorEmail:
                  type: string
                  format: email
                  description: >-
                    Import mode (org owner/admin only, else 403): attribute the
                    comment to the org member with this email; unknown emails
                    fall back to the caller.
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/comments/{id}:
    parameters:
      - $ref: "#/components/parameters/Id"
    patch:
      operationId: updateComment
      tags: [comments]
      summary: Edit a comment (author-only)
      description: >-
        Only the comment's author may edit it — anyone else in the org gets
        403 ("only the author can edit a comment"). Editing sets the
        comment's modified timestamp (null until first edit — the "(edited)"
        marker).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [comment]
                properties:
                  comment:
                    $ref: "#/components/schemas/Comment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The tasks tool is turned off for the caller's organization, or
            the caller is not the comment's author ("only the author can edit
            a comment")
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteComment
      tags: [comments]
      summary: Delete a comment (author-only)
      description: >-
        Only the comment's author may delete it — anyone else in the org gets
        403 ("only the author can delete a comment").
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            The tasks tool is turned off for the caller's organization, or
            the caller is not the comment's author ("only the author can
            delete a comment")
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/activities:
    get:
      operationId: listActivities
      tags: [activity]
      summary: List an issue's activity history
      description: >-
        Property-change entries, oldest first, capped at 500, with the actor
        embedded. from/to hold raw values (ids, numbers, dates) that clients
        resolve for display.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Activities (at most 500)
          content:
            application/json:
              schema:
                type: object
                required: [activities]
                properties:
                  activities:
                    type: array
                    maxItems: 500
                    items:
                      $ref: "#/components/schemas/Activity"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/issues/{id}/attachments:
    get:
      operationId: listIssueAttachments
      tags: [attachments]
      summary: List an issue's attachments
      description: Newest first. Each url points at the public presigned-redirect route.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          description: Attachments
          content:
            application/json:
              schema:
                type: object
                required: [attachments]
                properties:
                  attachments:
                    type: array
                    items:
                      $ref: "#/components/schemas/Attachment"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  # Tasks notifications moved to the platform notifications service. Tasks now
  # emits `tasks.*` events to the platform outbox (backend/packages/eventkit)
  # and the shared feed/bell (notifications-api.easerix.com) serves them, so
  # there are no notification routes on the tasks API. See docs/notifications/PLAN.md.

  /v1/attachments:
    post:
      operationId: createAttachment
      tags: [attachments]
      summary: Upload a file (multipart, "file" field, ≤15MB)
      description: >-
        multipart/form-data upload to R2. Any linked issue (directly via
        issueId, or via the target comment's issue) must belong to the
        caller's org (else 404). 400 when the file is missing or larger than
        ~15MB; 501 when storage is not configured. Returns 200 (not 201).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                issueId:
                  type: string
                  format: uuid
                commentId:
                  type: string
                  format: uuid
      responses:
        "200":
          description: Uploaded
          content:
            application/json:
              schema:
                type: object
                required: [attachment]
                properties:
                  attachment:
                    $ref: "#/components/schemas/Attachment"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "501":
          $ref: "#/components/responses/StorageUnconfigured"

  /v1/attachments/{id}:
    get:
      operationId: getAttachment
      tags: [attachments]
      summary: Fetch an attachment (no auth — presigned redirect)
      description: >-
        PUBLIC — deliberately unauthenticated and not org-checked so presigned
        redirects work directly in an <img src>: the unguessable attachment
        UUID is the access capability. 302-redirects to a presigned R2 URL
        valid for 24 hours. 501 when storage is not configured.
      security: []
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "302":
          description: Redirect to the presigned file URL
          headers:
            Location:
              schema:
                type: string
        "404":
          $ref: "#/components/responses/NotFound"
        "501":
          $ref: "#/components/responses/StorageUnconfigured"

  /v1/labels:
    get:
      operationId: listLabels
      tags: [labels]
      summary: List labels
      description: Labels across the org's teams, ordered by name. Filter with team_id.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: Labels
          content:
            application/json:
              schema:
                type: object
                required: [labels]
                properties:
                  labels:
                    type: array
                    items:
                      $ref: "#/components/schemas/Label"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createLabel
      tags: [labels]
      summary: Create a label
      description: >-
        Color defaults to "#95a2b3". The team must belong to the caller's org
        (else 404). `name` is stored trimmed and must be unique within the team,
        compared case-insensitively — a name already in use is a 409, whatever
        colour it is sent with. Two different teams may each use the same name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId, name]
              properties:
                teamId:
                  type: string
                  format: uuid
                name:
                  type: string
                color:
                  type: string
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [label]
                properties:
                  label:
                    $ref: "#/components/schemas/Label"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/labels/{id}:
    patch:
      operationId: updateLabel
      tags: [labels]
      summary: Update a label
      description: >-
        Rename or recolour. Omitted fields keep their stored value. A label's
        team is immutable, so teamId is not accepted — moving a label would
        detach it from every issue that carries it. A name another label in the
        same team uses is a 409, compared trimmed and case-insensitively; a
        label keeping its own name is not a collision. Unlike a cycle, a label
        has no number to fall back on, so a blank name is a 400 and never
        clears it. An empty color resets it to the default "#95a2b3". Editing
        a label changes it on every issue it is attached to. Labels in another
        org read as 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                color:
                  type: string
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [label]
                properties:
                  label:
                    $ref: "#/components/schemas/Label"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteLabel
      tags: [labels]
      summary: Delete a label
      description: Hard delete. The label disappears from issues it was attached to.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

  /v1/cycles:
    get:
      operationId: listCycles
      tags: [cycles]
      summary: List cycles
      description: >-
        Cycles across the org's teams, highest number first. Filter with
        team_id.
      parameters:
        - $ref: "#/components/parameters/TeamIdFilter"
      responses:
        "200":
          description: Cycles
          content:
            application/json:
              schema:
                type: object
                required: [cycles]
                properties:
                  cycles:
                    type: array
                    items:
                      $ref: "#/components/schemas/Cycle"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
    post:
      operationId: createCycle
      tags: [cycles]
      summary: Create a cycle
      description: >-
        number defaults to (max number in the team) + 1, which cannot collide;
        pinning an explicit number that is already taken is a 409, since
        (team_id, number) is unique. startsAt/endsAt accept YYYY-MM-DD. The
        team must belong to the caller's org (else 404). `name` is optional — an
        unnamed cycle is known by its number, and any number of unnamed cycles is
        fine — but a name that is set must be unique within the team, compared
        trimmed and case-insensitively, else 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [teamId]
              properties:
                teamId:
                  type: string
                  format: uuid
                number:
                  type: integer
                name:
                  type: string
                startsAt:
                  type: string
                  format: date
                  nullable: true
                endsAt:
                  type: string
                  format: date
                  nullable: true
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                required: [cycle]
                properties:
                  cycle:
                    $ref: "#/components/schemas/Cycle"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"

  /v1/cycles/{id}:
    patch:
      operationId: updateCycle
      tags: [cycles]
      summary: Update a cycle
      description: >-
        Partial update. Omitted fields keep their stored value — in particular
        an omitted number does NOT renumber the cycle. Send an empty string for
        startsAt/endsAt to clear the date; an unparseable date is a 400, not a
        silent null. endsAt must fall on or after startsAt once the patch is
        merged with the stored row. A name another cycle in the same team already
        uses is a 409; "" clears the name and never collides. A cycle's team is
        immutable, so teamId is not accepted. Cycles in another org read as 404.
      parameters:
        - $ref: "#/components/parameters/Id"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                number:
                  type: integer
                  minimum: 1
                name:
                  type: string
                startsAt:
                  type: string
                  description: YYYY-MM-DD, or "" to clear
                endsAt:
                  type: string
                  description: YYYY-MM-DD, or "" to clear
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [cycle]
                properties:
                  cycle:
                    $ref: "#/components/schemas/Cycle"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
    delete:
      operationId: deleteCycle
      tags: [cycles]
      summary: Delete a cycle
      description: >-
        Hard delete. Issues in the cycle are NOT deleted — they fall back to no
        cycle. To warn before deleting, count them with
        GET /v1/issues?cycle_id= first.
      parameters:
        - $ref: "#/components/parameters/Id"
      responses:
        "200":
          $ref: "#/components/responses/Message"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/ToolDisabled"
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
    TeamIdFilter:
      name: team_id
      in: query
      description: Restrict to one team.
      schema:
        type: string
        format: uuid

  responses:
    Message:
      description: Confirmation
      content:
        application/json:
          schema:
            type: object
            required: [message]
            properties:
              message:
                type: string
    BadRequest:
      description: Invalid request body (the error message names the offending field)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: >-
        Missing/invalid bearer token, or a token without org context
        ("token missing org context — refresh your session")
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    ToolDisabled:
      description: >-
        The tasks tool is turned off for the caller's organization
        ("this tool is turned off for your organization")
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: >-
        Not found — including rows that exist but belong to another org
        (org tenancy reads as 404, never 403)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: >-
        The change collides with an existing row (the error message names the
        collision)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    StorageUnconfigured:
      description: Attachment storage (R2) is not configured for this deployment
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Member:
      type: object
      required: [id, userId, name, email, created]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string
        created:
          type: string
          format: date-time

    ActorRef:
      type: object
      description: Compact member reference embedded in comments, activities, and notifications.
      required: [id, name, email]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        email:
          type: string

    Team:
      type: object
      required: [id, key, name, created]
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: Short issue-key prefix (e.g. "GEN").
        name:
          type: string
        created:
          type: string
          format: date-time

    State:
      type: object
      required: [id, teamId, name, type, color, position]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        type:
          type: string
          enum: [backlog, unstarted, started, completed, canceled]
        color:
          type: string
        position:
          type: integer

    Project:
      type: object
      required: [id, teamId, name, description, status, leadId, targetDate, created, modified]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
        status:
          type: string
          enum: [planned, in_progress, paused, completed, canceled]
        leadId:
          type: string
          format: uuid
          nullable: true
        targetDate:
          type: string
          format: date
          nullable: true
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time

    Label:
      type: object
      required: [id, teamId, name, color]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        name:
          type: string
        color:
          type: string

    Cycle:
      type: object
      required: [id, teamId, number, name, startsAt, endsAt]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        number:
          type: integer
        name:
          type: string
        startsAt:
          type: string
          format: date
          nullable: true
        endsAt:
          type: string
          format: date
          nullable: true

    Issue:
      type: object
      required:
        [
          id,
          teamId,
          projectId,
          number,
          title,
          description,
          stateId,
          state,
          priority,
          assigneeId,
          assignee,
          creatorId,
          parentId,
          cycleId,
          estimate,
          position,
          dueDate,
          labels,
          created,
          modified,
          completedAt,
        ]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        projectId:
          type: string
          format: uuid
          nullable: true
        number:
          type: integer
          description: Per-team incrementing issue number (team key + number is the display id).
        title:
          type: string
        description:
          type: string
        stateId:
          type: string
          format: uuid
        state:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/State"
        priority:
          type: integer
          description: 0 none, 1 urgent, 2 high, 3 medium, 4 low.
        assigneeId:
          type: string
          format: uuid
          nullable: true
        assignee:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/Member"
        creatorId:
          type: string
          format: uuid
        parentId:
          type: string
          format: uuid
          nullable: true
        cycleId:
          type: string
          format: uuid
          nullable: true
        estimate:
          type: integer
          nullable: true
          description: Null means "no estimate" (0 is normalized to null).
        position:
          type: number
        dueDate:
          type: string
          format: date
          nullable: true
        labels:
          type: array
          items:
            $ref: "#/components/schemas/Label"
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
          nullable: true
          description: Set when the issue moves to a completed-type state; cleared otherwise.

    Comment:
      type: object
      required: [id, issueId, authorId, body, created, modified, author]
      properties:
        id:
          type: string
          format: uuid
        issueId:
          type: string
          format: uuid
        authorId:
          type: string
          format: uuid
        body:
          type: string
        created:
          type: string
          format: date-time
        modified:
          type: string
          format: date-time
          nullable: true
          description: Null until the comment is edited — non-null means "(edited)".
        author:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ActorRef"

    Attachment:
      type: object
      required: [id, url, filename, contentType, size, created]
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          description: Absolute URL of the public presigned-redirect route (GET /v1/attachments/{id}).
        filename:
          type: string
        contentType:
          type: string
        size:
          type: integer
          format: int64
        created:
          type: string
          format: date-time

    Activity:
      type: object
      required: [id, issueId, actorId, actor, type, from, to, created]
      properties:
        id:
          type: string
          format: uuid
        issueId:
          type: string
          format: uuid
        actorId:
          type: string
          format: uuid
        actor:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ActorRef"
        type:
          type: string
          enum: [created, state, priority, assignee, due_date, project, cycle, estimate]
        from:
          type: string
          description: Raw previous value (id, number, or date) — empty for "created".
        to:
          type: string
          description: Raw new value (id, number, or date) — empty for "created".
        created:
          type: string
          format: date-time

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
