openapi: 3.1.0

info:
  title: Fareeqy REST API
  version: 1.0.0
  summary: Authenticated public REST API for Fareeqy project management.
  description: |
    The Fareeqy public REST API (`/api/v1`) lets a company automate its projects,
    task lists, tasks, majlis discussions, files, folders and calendar events over
    plain JSON with a Bearer API key.

    **Preview.** This surface is gated behind the per-company Flipper feature flag
    `rest_api` (off by default). While the flag is disabled for a company, every
    authenticated endpoint returns **404** — the surface is hidden, not merely
    forbidden. Authentication is checked first (a bad key is still 401), then the
    flag, so the 404 only appears once a valid key has been presented.

    With the flag on, the PLAN decides access: Free and Pro carry no API calls, so
    a key on either is refused **403** `plan_upgrade_required`.

    There is deliberately no "my tasks" or "my personal tasks" endpoint. A key
    belongs to the COMPANY and the person on it merely created it, so "assigned
    to me" has no answer a company credential can give. Both lists remain on the
    MCP server, whose Personal Access Token really is personal.

    ## Vocabulary

    Product vocabulary and the internal schema differ, and this API follows the
    **product**:

    | API term | Path segment | What it is |
    |---|---|---|
    | Task list | `/task-lists` | A container that groups tasks within a project (schema model `Task`) |
    | Task | `/tasks` | The actionable item a user checks off (schema model `Todo`) |
    | Majlis topic / discussion | `/discussions` | A project discussion thread |

    ## Identifiers

    - **Slugs** address records that have a FriendlyId slug: projects, task lists,
      tasks, and discussions. A slug is unique only **within its parent**, so URLs
      are fully nested and every slug is resolved through its parent (a task-list
      slug can repeat across projects). Never assume a slug is globally unique.
    - **Numeric ids** address records that have no slug: files, folders, events,
      and comments. These ids are global, so the API always drills a lookup through
      the parent project (or, for events, the caller's accessible set) — a numeric
      id from another company resolves to 404, never a leak.

    ## Authentication

    Send an `ApiKey` as an HTTP Bearer credential:

    ```
    Authorization: Bearer frq_api_xxxxxxxxxxxxxxxxxxxxxxxx
    ```

    API keys carry the `frq_api_` prefix and live in their own table, **isolated
    from the MCP server's Personal Access Tokens** (`frq_`): a key created for one
    surface cannot authenticate against the other.

    A key acts for the **company**, at the reach its own permissions describe. It
    sees every project the company has, private ones included, and it is not
    limited by the role of whoever created it. What bounds it is the operation
    allowlist below, the company itself (another company's record is a 404), and
    state that was never a permission: an archived project stays frozen and a
    read-only subscription stays read-only.

    ## Two-layer authorization

    Every endpoint declares the single **operation** it requires (a stable
    `<resource>:<capability>` string, e.g. `projects:write`). A key is gated twice:

    1. **Scope** (the ceiling) — `read` or `write`. A `write` key also carries
       `read`. The scope decides which operations a key may *ever* hold.
    2. **`allowed_operations`** (the operative gate) — the explicit list of
       operations this key may call, validated to be a subset of what its scope
       permits. Empty means "nothing"; a key is never accidentally open.

    Both must hold for a request to run: the operation must be in the key's
    allowlist AND its scope must satisfy the operation's capability, otherwise the
    request is **403 forbidden** and audited as `denied`.

    **Deletes are opt-in.** `destructive` operations need the `write` scope but are
    deliberately excluded from the write preset's default grant; an owner ticks each
    delete operation on a key deliberately. See the `x-operation-catalog` extension
    below for the full list.

    ## Envelopes

    - **Single resource** → `{ "data": <Resource> }`
    - **Collection** → `{ "data": [<Resource>], "meta": { total, limit, offset, count } }`
    - **Delete** → `{ "data": { "deleted": true, ... } }`
    - **Error** → `{ "error": { "code": "<slug>", "message": "<human message>" } }`

    ## Statuses every endpoint can return

    Beyond the per-operation list below, the base controller rescues at one place, so
    the same failures reach every endpoint: **401** (bad or missing key), **403**
    (`forbidden` or `plan_upgrade_required`), **404** (not found, not accessible, or
    the `rest_api` flag is off for the company), **429** (burst throttle or spent
    daily allowance). Any **write** can additionally return **409** `conflict` when a
    uniqueness constraint loses a race; it is safe to retry.

    ## Quickstart

    Every `x-codeSamples` snippet below assumes these two variables, so set them
    once and the samples paste as-is:

    ```bash
    export FRQ_BASE="https://app.fareeqy.com/api/v1"
    export FRQ_KEY="frq_api_..."          # shown once, at creation
    ```

    Confirm the key before anything else. `GET /me` describes the **key** rather
    than a person, and returns the exact operation list it carries:

    ```bash
    curl -s "$FRQ_BASE/me" -H "Authorization: Bearer $FRQ_KEY"
    ```

    The whole create-a-project-with-work-in-it flow, end to end. Note that ids are
    **slugs**, and that a slug is captured from the response rather than typed —
    Arabic slugs are the norm here, so letting the API hand you the value avoids
    percent-encoding it yourself:

    ```bash
    SLUG=$(curl -s -X POST "$FRQ_BASE/projects" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"name":"Website launch","start_date":"2026-08-05"}' | jq -r .data.id)

    LIST=$(curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"title":"Before launch","priority":"high"}' | jq -r .data.id)

    TASK=$(curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks" \
      -H "Authorization: Bearer $FRQ_KEY" -H "Content-Type: application/json" \
      -d '{"title":"Proofread the homepage","due_at":"2026-08-20"}' | jq -r .data.id)

    curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks/$TASK/complete" \
      -H "Authorization: Bearer $FRQ_KEY"
    ```

    Two things that trip up a first integration:

    - **Request bodies are flat.** Send `{"name": "..."}`, never
      `{"project": {"name": "..."}}`. A wrapped body is not an error — the fields
      simply are not found, and you get a validation failure naming a field you
      thought you sent.
    - **Unknown fields are dropped silently** (strong parameters). Misspelling
      `titel` does not fail; it creates a record without a title.

    ## Pagination

    Collections take `limit` (default 50, max 100, clamped) and `offset` (default 0)
    query params. `meta.total` is the full unfiltered count; `meta.count` is the
    number of records in the returned page.

    ## Rate limiting and the daily quota

    Two independent layers, and they refuse differently.

    **Burst.** Each API key is throttled to **300 requests per minute** (Rack::Attack
    `api_v1/token`, keyed on a hash of the Bearer header), and a blanket 300/min
    per-IP throttle (`api/ip`) also applies. Crossing either returns **429**.

    **Daily quota.** Every request also spends one call from the company's daily API
    allowance. The allowance is per **company**, not per key, so minting extra keys
    does not raise it, and the day resets at midnight in the company's own time zone.

    | Plan | Daily API calls |
    |---|---|
    | Free (المجاني) | 0 (no API access) |
    | Pro (الاحترافي) | 0 (no API access) |
    | Advanced (المتطور) | 1,000 |
    | Productive (الانتاجي) | 10,000 |

    A plan at **0** is not being rate-limited, it has no API access at all: the
    refusal is **403 `plan_upgrade_required`** and carries no `Retry-After`, because
    waiting will never help. A plan that *has* access and has spent today's calls
    gets **429 `rate_limit_exceeded`** with a `Retry-After` pointing at the company's
    next midnight. Treating those two as the same failure sends a well-behaved client
    into a retry loop that can never succeed.

    `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` are set on
    **every** served response, not only on refusals, so a client can slow down before
    it reaches zero. On an uncapped plan the first two read `unlimited`.
    `X-RateLimit-Reset` is omitted when the plan carries no access, for the same
    reason `Retry-After` is.

  x-operation-catalog:
    description: >
      The full operation catalog (Api::OperationRegistry). read needs the read
      scope; write needs the write scope; destructive needs the write scope AND is
      excluded from the write preset's default grant (opt-in per key). `comments`
      has no read operation, and `account` has no destructive operation.
    account:
      read: [account:read]
    projects:
      read: [projects:read]
      write: [projects:write]
      destructive: [projects:destructive]
    task_lists:
      read: [task_lists:read]
      write: [task_lists:write]
      destructive: [task_lists:destructive]
    tasks:
      read: [tasks:read]
      write: [tasks:write]
      destructive: [tasks:destructive]
    discussions:
      read: [discussions:read]
      write: [discussions:write]
      destructive: [discussions:destructive]
    comments:
      write: [comments:write]
    files:
      read: [files:read]
      write: [files:write]
      destructive: [files:destructive]
    events:
      read: [events:read]
      write: [events:write]
      destructive: [events:destructive]

servers:
  - url: https://app.fareeqy.com/api/v1
    description: Production. The app runs on app.fareeqy.com (fareeqy.com is the
      marketing site and answers no API call). The base path (/api/v1) is part of
      the server URL, so operation paths below are written relative to it
      (e.g. /projects).

security:
  - bearerAuth: []

tags:
  - name: Account
    description: The identity of the calling key, and unified workspace search.
  - name: Personal tasks
    description: The caller's private to-dos, outside every project.
  - name: Events
    description: Company calendar events. Numeric ids.
  - name: Projects
    description: Projects the caller can access.
  - name: Members
    description: A project's assignable people.
  - name: Task lists
    description: Containers that group tasks within a project. Slug ids.
  - name: Tasks
    description: The actionable items nested under a task list. Slug ids.
  - name: Comments
    description: Comments on a task list or task.
  - name: Discussions
    description: Majlis discussion topics within a project. Slug ids.
  - name: Files
    description: Project files and external links. Numeric ids.
  - name: Folders
    description: Folders in a project's files section. Numeric ids.

paths:

  # ─────────────────────────── Account ───────────────────────────
  /me:
    get:
      tags: [Account]
      operationId: getMe
      summary: The calling key and the company it acts for
      description: |
        Returns the key itself — its name, its access level and the exact
        operations it may call — plus the company it acts for. The canonical
        "is my key valid, and what does it reach?" check. `created_by` names the
        person who created the key; it is a signature, not the identity the call
        runs as. Requires operation `account:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/me" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The authenticated account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /search:
    get:
      tags: [Account]
      operationId: search
      summary: Unified workspace search
      description: |
        Finds projects, task lists, tasks, folders, files and majlis topics by name
        (Arabic-normalized, access-scoped). Returns the slugs/ids the other
        endpoints take. Each kind is capped independently (`per_type`); `truncated`
        names every kind that had more matches than were returned. A blank `q`
        returns empty results. Requires operation `account:read`.
      parameters:
        - name: q
          in: query
          description: The search term. Blank returns empty results.
          schema: { type: string }
        - name: types
          in: query
          description: Restrict to these kinds. Omit for all kinds. Unknown names are ignored.
          style: form
          explode: true
          schema:
            type: array
            items:
              type: string
              enum: [project, task_list, task, folder, file, discussion]
        - name: per_type
          in: query
          description: Max hits per kind (default 5, max 25, clamped).
          schema: { type: integer, default: 5, minimum: 1, maximum: 25 }
      responses:
        '200':
          description: Search results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      query: { type: string }
                      results:
                        type: array
                        items: { $ref: '#/components/schemas/SearchResult' }
                      truncated:
                        type: array
                        description: The kinds that had more matches than were returned.
                        items: { type: string }
              example:
                data:
                  query: تصميم
                  results:
                    - { type: project, title: تطوير الموقع, project_slug: تطوير-الموقع, archived: false }
                    - type: task
                      title: تصميم الهيدر
                      project_slug: تطوير-الموقع
                      task_list_slug: الصفحة-الرئيسية
                      task_slug: تصميم-الهيدر
                      completed: false
                    - { type: folder, name: التصاميم, project_slug: تطوير-الموقع, folder_id: 42 }
                  truncated: [task]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────── Personal tasks ────────────────────────
  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: List calendar events
      description: |
        The caller's accessible events (organized or invited). Defaults to upcoming
        only; an explicit `from`/`to` window wins over `include_past`. Bounds and the
        "upcoming" cutoff resolve in the company time zone. Requires operation
        `events:read`.
      parameters:
        - name: from
          in: query
          description: Lower bound on start time (ISO 8601). An explicit window overrides include_past.
          schema: { type: string }
        - name: to
          in: query
          description: Upper bound on start time (ISO 8601).
          schema: { type: string }
        - name: include_past
          in: query
          description: When no from/to window is given, include past events.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Events]
      operationId: createEvent
      summary: Create an event
      description: |
        Routes through the shared Events::Creator, so attendees are notified and the
        event syncs to a connected Google Calendar. Naive times parse in the company
        zone. Requires operation `events:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, starts_at, ends_at]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                starts_at: { type: string, description: ISO 8601 (e.g. 2026-07-20T14:00). }
                ends_at: { type: string, description: ISO 8601. }
                all_day: { type: boolean, default: false }
                meeting_url: { type: [string, 'null'] }
                attendee_emails:
                  type: array
                  description: Emails of assignable company users to invite.
                  items: { type: string, format: email }
            example:
              name: اجتماع مراجعة التصاميم
              description: نراجع نسخة الهيدر ونقرر الاتجاه النهائي.
              starts_at: '2026-08-05T13:00'
              ends_at: '2026-08-05T14:00'
              all_day: false
              meeting_url: https://meet.google.com/abc-defg-hij
              attendee_emails: [abdullah@example.com]
      responses:
        '201':
          description: The created event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /events/{id}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      tags: [Events]
      operationId: getEvent
      summary: Get an event
      description: Requires operation `events:read`.
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Events]
      operationId: updateEvent
      summary: Update an event
      description: |
        Organizer-only (EventPolicy#update?). Only passed fields change, EXCEPT
        `attendee_emails`, which replaces the whole guest list when present. Requires
        operation `events:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                starts_at: { type: string, description: ISO 8601. }
                ends_at: { type: string, description: ISO 8601. }
                all_day: { type: boolean }
                meeting_url: { type: [string, 'null'] }
                attendee_emails:
                  type: array
                  description: When present, REPLACES the entire attendee list.
                  items: { type: string, format: email }
            examples:
              rename_only:
                summary: Change the title and nothing else
                description: >
                  `attendee_emails` is absent, so the guest list is left exactly as it
                  was. Times are untouched too.
                value:
                  name: اجتماع مراجعة التصاميم (نسخة مُحدثة)
              replace_guest_list:
                summary: Replace the whole guest list
                description: >
                  `attendee_emails` is PRESENT, so it is the complete new list. Anyone
                  missing from it is uninvited. Sending an empty array removes everyone.
                value:
                  attendee_emails: [abdullah@example.com, sara@example.com]
      responses:
        '200':
          description: The updated event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Events]
      operationId: deleteEvent
      summary: Delete an event
      description: |
        Permanent, organizer-only. Cascades to personal copies others made of a
        public event. Requires operation `events:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          event:
                            type: object
                            properties:
                              id: { type: integer }
                              name: { type: string }
              example:
                data:
                  deleted: true
                  event: { id: 77, name: اجتماع مراجعة التصاميم }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ────────────────────────── Projects ───────────────────────────
  /projects:
    get:
      tags: [Projects]
      operationId: listProjects
      summary: List accessible projects
      description: |
        Projects the caller can access (owned / public / direct-member /
        team-member), newest first. Excludes archived unless `include_archived`.
        Requires operation `projects:read`.
      parameters:
        - name: include_archived
          in: query
          description: Include archived projects.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects?limit=20&offset=0&include_archived=false" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of projects.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Projects]
      operationId: createProject
      summary: Create a project
      description: Requires operation `projects:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                start_date: { type: [string, 'null'], description: ISO 8601 date. }
                end_date: { type: [string, 'null'], description: ISO 8601 date. }
                is_public: { type: boolean }
            example:
              name: تطوير الموقع
              description: إعادة بناء الموقع التعريفي وربطه بالمنتج.
              start_date: '2026-07-01'
              end_date: '2026-09-30'
              is_public: false
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X POST "$FRQ_BASE/projects" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "name": "Website launch",
                    "description": "Everything before we ship",
                    "start_date": "2026-08-05",
                    "end_date": "2026-09-30",
                    "is_public": true
                  }'
      responses:
        '201':
          description: The created project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    get:
      tags: [Projects]
      operationId: getProject
      summary: Get a project
      description: Requires operation `projects:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # $SLUG is the project slug returned as `data.id` on create.
            curl -s "$FRQ_BASE/projects/$SLUG" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Projects]
      operationId: updateProject
      summary: Update a project
      description: Requires operation `projects:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                start_date: { type: [string, 'null'] }
                end_date: { type: [string, 'null'] }
                is_public: { type: boolean }
            example:
              description: إعادة بناء الموقع التعريفي وربطه بالمنتج وبلوحة القياس.
              end_date: '2026-10-15'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X PATCH "$FRQ_BASE/projects/$SLUG" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"description": "Scope agreed with the client"}'
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Projects]
      operationId: deleteProject
      summary: Delete a project
      description: |
        Permanent, cascading delete (demands `manage_projects`). Prefer archive.
        The response reports the counts destroyed. Requires operation
        `projects:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          project:
                            type: object
                            properties:
                              name: { type: string }
                              slug: { type: string }
                          destroyed:
                            type: object
                            properties:
                              task_lists: { type: integer }
                              tasks: { type: integer }
                              discussions: { type: integer }
                              files: { type: integer }
                              folders: { type: integer }
              example:
                data:
                  deleted: true
                  project: { name: تطوير الموقع, slug: تطوير-الموقع }
                  destroyed: { task_lists: 4, tasks: 27, discussions: 3, files: 12, folders: 2 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}/archive:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    post:
      tags: [Projects]
      operationId: archiveProject
      summary: Archive a project
      description: |
        Reversible put-away; records a timeline event and freezes writes across the
        project. Prefer this over delete. Requires operation `projects:write`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # The reversible put-away. Prefer it to DELETE, which cascades permanently.
            curl -s -X POST "$FRQ_BASE/projects/$SLUG/archive" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The archived project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{id}/unarchive:
    parameters:
      - $ref: '#/components/parameters/ProjectSlugId'
    post:
      tags: [Projects]
      operationId: unarchiveProject
      summary: Unarchive a project
      description: Requires operation `projects:write`.
      responses:
        '200':
          description: The reactivated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ────────────────────────── Members ────────────────────────────
  /projects/{project_id}/members:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Members]
      operationId: listProjectMembers
      summary: List a project's assignable people
      description: |
        The people who can be assigned work on a project — the emails
        `assignee_email` (tasks) and `attendee_emails` (events) resolve against.
        Not paginated. Requires operation `projects:read`.
      responses:
        '200':
          description: The assignable members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Member' }
              example:
                data:
                  - { name: سارة العتيبي, email: sara@example.com, role: مدير }
                  - { name: عبدالله المطيري, email: abdullah@example.com, role: عضو فريق }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────── Task lists ──────────────────────────
  /projects/{project_id}/task-lists:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Task lists]
      operationId: listTaskLists
      summary: List a project's task lists
      description: Requires operation `task_lists:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$SLUG/task-lists" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of task lists.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/TaskList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Task lists]
      operationId: createTaskList
      summary: Create a task list
      description: Requires operation `task_lists:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                notes: { type: [string, 'null'], description: Rich-text notes (plain text in). }
                priority: { $ref: '#/components/schemas/Priority' }
            example:
              title: الصفحة الرئيسية
              notes: نبدأ بالهيدر ثم قسم المزايا.
              priority: high
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "title": "Before launch",
                    "notes": "Everything that must close before we publish",
                    "priority": "high"
                  }'
      responses:
        '201':
          description: The created task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    get:
      tags: [Task lists]
      operationId: getTaskList
      summary: Get a task list
      description: Requires operation `task_lists:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$SLUG/task-lists/$LIST" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Task lists]
      operationId: updateTaskList
      summary: Update a task list
      description: Requires operation `task_lists:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                notes: { type: [string, 'null'] }
                priority: { $ref: '#/components/schemas/Priority' }
            example:
              priority: urgent
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s -X PATCH "$FRQ_BASE/projects/$SLUG/task-lists/$LIST" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"priority": "urgent"}'
      responses:
        '200':
          description: The updated task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Task lists]
      operationId: deleteTaskList
      summary: Delete a task list
      description: |
        Permanent, cascading (destroys its tasks). Demands
        `delete_any_task_or_list`, no creator bypass. Requires operation
        `task_lists:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          task_list:
                            type: object
                            properties:
                              title: { type: string }
                              slug: { type: string }
                          deleted_tasks: { type: integer }
              example:
                data:
                  deleted: true
                  task_list: { title: الصفحة الرئيسية, slug: الصفحة-الرئيسية }
                  deleted_tasks: 9
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: completeTaskList
      summary: Mark a task list complete
      description: |
        Open to any project member (a workflow action, not a content edit).
        Requires operation `task_lists:write`.
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: reopenTaskList
      summary: Reopen a task list
      description: Open to any project member. Requires operation `task_lists:write`.
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{id}/move:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListSlugId'
    post:
      tags: [Task lists]
      operationId: moveTaskList
      summary: Move a task list to another project
      description: |
        Moves the list (with its tasks) into another project. The caller must be
        allowed to create a list in the destination (refuses an archived target).
        Tasks assigned to a non-member of the target are unassigned. Requires
        operation `task_lists:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_project_slug]
              properties:
                target_project_slug: { type: string, description: Slug of the destination project. }
            example:
              target_project_slug: تطبيق-الجوال
      responses:
        '200':
          description: The moved task list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskListEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/comments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
    post:
      tags: [Comments]
      operationId: createTaskListComment
      summary: Comment on a task list
      description: Requires operation `comments:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string, description: Rich-text comment body (plain text in). }
            example:
              content: خلصنا الهيدر، باقي قسم المزايا.
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────── Tasks ────────────────────────────
  /projects/{project_id}/task-lists/{task_list_id}/tasks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
    get:
      tags: [Tasks]
      operationId: listTasks
      summary: List a task list's tasks
      description: Requires operation `tasks:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks?limit=100" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: A page of tasks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskCollection' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Tasks]
      operationId: createTask
      summary: Create a task
      description: |
        Creates a task in the list. `assignee_email` must name an assignable member
        (see the project's members). Requires operation `tasks:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                notes: { type: [string, 'null'], description: Rich-text notes (plain text in). }
                due_at:
                  type: [string, 'null']
                  description: >
                    Due DATE (YYYY-MM-DD, e.g. 2026-07-20). A task is due on a day, not at a
                    moment: the column is a `date`, so a value carrying a time is accepted and
                    the time is then dropped. Send `2026-07-20T14:00` and you get `2026-07-20`
                    back.
                assignee_email: { type: [string, 'null'], format: email, description: Email of an assignable member. }
            example:
              title: تصميم الهيدر
              notes: نحتاج نسخة للجوال ونسخة للديسكتوب.
              due_at: '2026-08-10'
              assignee_email: abdullah@example.com
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # assignee_email must name a member of THIS project (or the project must be
            # public), otherwise the call is a 422 naming the address. Omit it to leave
            # the task unassigned.
            curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                    "title": "Proofread the homepage",
                    "notes": "Copy edit plus terminology pass",
                    "due_at": "2026-08-20",
                    "assignee_email": "sara@example.com"
                  }'
      responses:
        '201':
          description: The created task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/tasks/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    get:
      tags: [Tasks]
      operationId: getTask
      summary: Get a task
      description: Requires operation `tasks:read`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            curl -s "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks/$TASK" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Tasks]
      operationId: updateTask
      summary: Update a task
      description: |
        Only passed fields change. Passing `assignee_email` (even empty) reassigns;
        an empty value unassigns. Requires operation `tasks:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                notes: { type: [string, 'null'] }
                due_at:
                  type: [string, 'null']
                  description: >
                    New due DATE (YYYY-MM-DD). Any time component is dropped — see the
                    create operation.
                assignee_email:
                  type: [string, 'null']
                  description: >
                    Email of an assignable member. An empty string unassigns, which is
                    why this carries no `email` format: the empty value is a real,
                    accepted input here.
            examples:
              reschedule:
                summary: Push the due date out
                value:
                  due_at: '2026-08-17'
              unassign:
                summary: Take the assignee off
                description: An empty string unassigns. Omitting the key leaves the assignee alone.
                value:
                  assignee_email: ''
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # An empty assignee_email unassigns; omitting the key leaves the assignee alone.
            curl -s -X PATCH "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks/$TASK" \
              -H "Authorization: Bearer $FRQ_KEY" \
              -H "Content-Type: application/json" \
              -d '{"due_at": "2026-08-24", "assignee_email": ""}'
      responses:
        '200':
          description: The updated task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Tasks]
      operationId: deleteTask
      summary: Delete a task
      description: |
        Permanent. Demands `delete_any_task_or_list`, no creator bypass. Requires
        operation `tasks:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          task:
                            type: object
                            properties:
                              title: { type: string }
                              slug: { type: string }
              example:
                data:
                  deleted: true
                  task: { title: تصميم الهيدر, slug: تصميم-الهيدر }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/tasks/{id}/complete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: completeTask
      summary: Mark a task complete
      description: |
        Open to any project member (a workflow action). Idempotent — completing an
        already-complete task is a no-op. Requires operation `tasks:write`.
      x-codeSamples:
        - lang: cURL
          label: curl
          source: |
            # Idempotent: completing an already-complete task is a no-op 200.
            curl -s -X POST "$FRQ_BASE/projects/$SLUG/task-lists/$LIST/tasks/$TASK/complete" \
              -H "Authorization: Bearer $FRQ_KEY"
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/tasks/{id}/incomplete:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: reopenTask
      summary: Reopen a task
      description: Open to any project member. Idempotent. Requires operation `tasks:write`.
      responses:
        '200':
          description: The task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/tasks/{id}/move:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskSlugId'
    post:
      tags: [Tasks]
      operationId: moveTask
      summary: Move a task to another list in the same project
      description: |
        Moves the task to a different list in the SAME project (appended to the
        end). The destination's edit permission is re-checked. Requires operation
        `tasks:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_task_list_slug]
              properties:
                target_task_list_slug: { type: string, description: Slug of the destination task list (same project). }
            example:
              target_task_list_slug: صفحة-التسعير
      responses:
        '200':
          description: The moved task.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/task-lists/{task_list_id}/tasks/{task_id}/comments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/TaskListId'
      - $ref: '#/components/parameters/TaskId'
    post:
      tags: [Comments]
      operationId: createTaskComment
      summary: Comment on a task
      description: Requires operation `comments:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string, description: Rich-text comment body (plain text in). }
            example:
              content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
      responses:
        '201':
          description: The created comment.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
              # A task's comments are a conversation between several people, and a
              # review comment is a paragraph, not a sentence. These variants show
              # both, so a caller sizes its UI for what the endpoint really returns.
              examples:
                short:
                  summary: A quick reply
                  value:
                    data:
                      id: 512
                      content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
                      author: عبدالله المطيري
                      created_at: '2026-07-15T09:48:03.000+03:00'
                review:
                  summary: A long review comment from another member
                  value:
                    data:
                      id: 518
                      content: >-
                        راجعت الهيدر على ثلاثة مقاسات، وعندي ثلاث ملاحظات.


                        الأولى أن الشعار يقفز سطراً كاملاً تحت 380 بكسل لأن العنصر
                        المجاور له لا ينكمش، فيظهر فراغ أبيض في أعلى الصفحة على
                        أجهزة قديمة ما زالت تمثل نسبة معتبرة من زوارنا.


                        الثانية أن القائمة المنسدلة تفتح لليسار في الوضع العربي،
                        وهذا يخالف اتجاه القراءة ويجعلها تخرج خارج الشاشة.


                        الثالثة بسيطة: وزن الخط في الروابط 500 بينما بقية الموقع
                        على 400، فيبدو الهيدر أثقل مما حوله. رفعت لقطة للحالات
                        الثلاث في مجلد «التصاميم».
                      author: نورة الحربي
                      created_at: '2026-07-15T11:20:41.000+03:00'
                decision:
                  summary: A closing note from the project owner
                  value:
                    data:
                      id: 524
                      content: >-
                        خذوا ملاحظات نورة كلها، وابدأوا بالثانية لأنها تكسر
                        الاستخدام لا الشكل. نراجع الاثنين القادم.
                      author: سارة العتيبي
                      created_at: '2026-07-15T14:05:12.000+03:00'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────── Discussions ─────────────────────────
  /projects/{project_id}/discussions:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Discussions]
      operationId: listDiscussions
      summary: List a project's majlis topics
      description: Requires operation `discussions:read`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: A page of discussions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/CollectionMeta'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Discussion' }
              # A real majlis board: several authors, bodies of the length people
              # actually write, and a topic whose decision points at an uploaded
              # file. A one-line example from a single author teaches a shape the
              # caller will never receive.
              example:
                data:
                  - id: قرار-توحيد-هوية-التقارير
                    title: 'قرار: توحيد هوية التقارير المُصدَّرة'
                    body: >-
                      اتفقنا في اجتماع الاثنين على أن كل تقرير يخرج من فريقي يحمل
                      هوية الشركة نفسها: الشعار في الترويسة، ولون العناوين
                      #00674F، والتاريخ بالهجري والميلادي معاً في الصفحة الأولى.


                      السبب أن التقارير التي أرسلناها للمستثمرين الربع الماضي خرجت
                      بثلاثة أشكال مختلفة لأن كل واحد منا صدّرها من شاشة مختلفة،
                      فبدت وكأنها من ثلاث شركات.


                      التنفيذ على فريق التصميم قبل نهاية الشهر، والمرجع ملف الهوية
                      المرفق في مجلد «الهوية البصرية». من عنده اعتراض فليكتبه هنا
                      قبل الأحد، وبعدها يصير القرار نافذاً.
                    category: قرار
                    author: سارة العتيبي
                    replies_count: 7
                    created_at: '2026-07-21T09:12:44.000+03:00'
                  - id: اقتراح-لتحسين-صفحة
                    title: اقتراح لتحسين صفحة التسعير
                    body: >-
                      أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.


                      لاحظت من تسجيلات الجلسات أن الزائر يفتح البطاقات الأربع
                      واحدة بعد الأخرى ثم يعود للأعلى، وهذا يعني أنه يحاول المقارنة
                      ولا تسعفه الواجهة. الجدول يحل هذا في نظرة واحدة.
                    category: فكرة
                    author: عبدالله المطيري
                    replies_count: 4
                    created_at: '2026-07-08T14:02:55.000+03:00'
                  - id: تنبيه-إجازة-فريق-التطوير
                    title: إجازة فريق التطوير الأسبوع القادم
                    body: >-
                      فريق التطوير في إجازة من الأحد إلى الثلاثاء. أي طلب عاجل
                      يُرفع هنا وسنتابعه، وما عدا ذلك يُجدول بعد العودة.
                    category: إعلان
                    author: نورة الحربي
                    replies_count: 2
                    created_at: '2026-07-02T08:30:10.000+03:00'
                meta: { total: 18, limit: 50, offset: 0, count: 3 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Discussions]
      operationId: createDiscussion
      summary: Create (and publish) a majlis topic
      description: |
        Creates and PUBLISHES the topic — subscribing its audience, notifying them,
        and recording the timeline event. Requires operation `discussions:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                body: { type: [string, 'null'], description: Rich-text body (plain text in). }
            example:
              title: اقتراح لتحسين صفحة التسعير
              body: أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.
      responses:
        '201':
          description: The created topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/discussions/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DiscussionSlugId'
    get:
      tags: [Discussions]
      operationId: getDiscussion
      summary: Get a majlis topic
      description: Requires operation `discussions:read`.
      responses:
        '200':
          description: The topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [Discussions]
      operationId: updateDiscussion
      summary: Update a majlis topic
      description: Requires operation `discussions:write`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                body: { type: [string, 'null'] }
            example:
              body: أقترح نعرض الباقات في جدول مقارنة واحد، ونثبت زر التجربة أعلى الصفحة.
      responses:
        '200':
          description: The updated topic.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscussionEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Discussions]
      operationId: deleteDiscussion
      summary: Delete a majlis topic
      description: |
        SOFT delete (recoverable by an admin). Requires operation
        `discussions:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          recoverable: { type: boolean }
                          discussion:
                            type: object
                            properties:
                              title: { type: string }
                              slug: { type: string }
              example:
                data:
                  deleted: true
                  recoverable: true
                  discussion: { title: اقتراح لتحسين صفحة التسعير, slug: اقتراح-لتحسين-صفحة }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/discussions/{id}/reply:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/DiscussionSlugId'
    post:
      tags: [Discussions]
      operationId: replyToDiscussion
      summary: Reply to a majlis topic
      description: |
        Adds a reply. The reply is serialized with the same shape as a Comment.
        Requires operation `discussions:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content: { type: string, description: Rich-text reply body (plain text in). }
            example:
              content: أتفق. الجدول أوضح، بشرط نبقي الأسعار ظاهرة بدون ضغط إضافي.
      responses:
        '201':
          description: The created reply.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CommentEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ──────────────────────────── Files ────────────────────────────
  /projects/{project_id}/files:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Files]
      operationId: listFiles
      summary: List one level of a project's file browser
      description: |
        The folders and files directly inside `folder_id` (or the project root).
        Not recursive — walk down by passing a subfolder's id back. Files are
        paginated; folders are not. Requires operation `files:read`.
      parameters:
        - name: folder_id
          in: query
          description: The folder to list inside; omit for the project root.
          schema: { type: integer }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: One level of the file browser.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      folder:
                        oneOf:
                          - $ref: '#/components/schemas/Folder'
                          - type: 'null'
                        description: The folder being listed, or null at the project root.
                      folders:
                        type: array
                        items: { $ref: '#/components/schemas/Folder' }
                      files:
                        type: array
                        items: { $ref: '#/components/schemas/ProjectFile' }
                  meta:
                    type: object
                    properties:
                      total: { type: integer, description: Total files in this folder (folders are not paginated). }
                      limit: { type: integer }
                      offset: { type: integer }
              example:
                data:
                  folder:
                    id: 42
                    name: التصاميم
                    path: التصاميم
                    description: ملفات الهوية وواجهات الموقع.
                    parent_folder_id: null
                    created_at: '2026-07-05T08:20:00.000+03:00'
                  folders:
                    - id: 51
                      name: الأيقونات
                      path: التصاميم/الأيقونات
                      description: null
                      parent_folder_id: 42
                      created_at: '2026-07-06T09:02:14.000+03:00'
                  files:
                    - id: 108
                      name: الهوية-البصرية.pdf
                      path: التصاميم/الهوية-البصرية.pdf
                      folder_id: 42
                      content_type: application/pdf
                      size_bytes: 2418176
                      size_human: '2.31 م.بايت'
                      external_url: null
                      source: null
                      scan_state: clean
                      downloadable: true
                      uploaded_by: سارة العتيبي
                      created_at: '2026-07-10T16:31:44.000+03:00'
                    - id: 131
                      name: واجهات الصفحة الرئيسية
                      path: التصاميم/واجهات الصفحة الرئيسية
                      folder_id: 42
                      content_type: null
                      size_bytes: null
                      size_human: null
                      external_url: https://www.figma.com/design/9aZq/home-v3
                      source: Figma
                      scan_state: skipped
                      downloadable: true
                      uploaded_by: سارة العتيبي
                      created_at: '2026-07-12T11:04:20.000+03:00'
                    # Media uploads: an image and a video, so a caller can see what
                    # content_type / size_bytes / scan_state look like on real binaries
                    # rather than only on a PDF and an external link. The video is
                    # mid-scan, which is the one state that flips `downloadable`.
                    - id: 147
                      name: لقطة-الصفحة-الرئيسية.png
                      path: التصاميم/لقطة-الصفحة-الرئيسية.png
                      folder_id: 42
                      content_type: image/png
                      size_bytes: 884736
                      size_human: '864 ك.بايت'
                      external_url: null
                      source: null
                      scan_state: clean
                      downloadable: true
                      uploaded_by: عبدالله المطيري
                      created_at: '2026-07-14T10:22:07.000+03:00'
                    - id: 152
                      name: عرض-التدفق-الجديد.mp4
                      path: التصاميم/عرض-التدفق-الجديد.mp4
                      folder_id: 42
                      content_type: video/mp4
                      size_bytes: 41943040
                      size_human: '40 م.بايت'
                      external_url: null
                      source: null
                      scan_state: pending
                      downloadable: false
                      uploaded_by: نورة الحربي
                      created_at: '2026-07-15T13:47:58.000+03:00'
                meta: { total: 4, limit: 50, offset: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Files]
      operationId: uploadFile
      summary: Upload a file from inline content
      description: |
        Uploads a file from inline `content` (UTF-8 text) OR `content_base64`
        (binary, standard base64, no line breaks) — exactly one, not both. Capped at
        5 MB decoded. A freshly uploaded file is `pending` (not downloadable) until
        the virus scan clears it. Requires operation `files:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, description: The file name (with extension). }
                folder_id: { type: [integer, 'null'], description: Destination folder; omit for the project root. }
                content: { type: [string, 'null'], description: UTF-8 text content. Provide this OR content_base64. }
                content_base64: { type: [string, 'null'], description: Base64-encoded bytes. Provide this OR content. }
            examples:
              text_upload:
                summary: UTF-8 text, straight into the project root
                value:
                  name: ملاحظات-الإطلاق.md
                  content: |
                    # ملاحظات الإطلاق
                    راجعنا الهيدر وقسم المزايا.
              binary_upload:
                summary: Binary bytes into a folder
                description: Standard base64, no line breaks. Capped at 5 MB decoded.
                value:
                  name: الهوية-البصرية.pdf
                  folder_id: 42
                  content_base64: JVBERi0xLjcKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c+PgplbmRvYmoK
      responses:
        '201':
          description: The uploaded file.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectFileEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/files/link:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Files]
      operationId: addExternalLink
      summary: Add an external link
      description: |
        Adds an external link (Figma, Drive, Notion...) — no storage, no scan, no
        size limit. The URL's domain is validated against `source_type`. Requires
        operation `files:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, url, source_type]
              properties:
                name: { type: string }
                url: { type: string, format: uri }
                source_type: { $ref: '#/components/schemas/ExternalSourceType' }
                folder_id: { type: [integer, 'null'], description: Destination folder; omit for the project root. }
            examples:
              figma:
                summary: A Figma design file
                value:
                  name: واجهات الصفحة الرئيسية
                  url: https://www.figma.com/design/9aZq/home-v3
                  source_type: figma
                  folder_id: 42
              notion:
                summary: A Notion page
                description: >
                  The URL's domain is validated against `source_type`, so a notion.so
                  link declared as `google_drive` is a 422, not a silent mislabel.
                value:
                  name: خطة المحتوى
                  url: https://www.notion.so/fareeqy/content-plan-q3
                  source_type: notion
      responses:
        '201':
          description: The created link.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectFileEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/files/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/FileId'
    delete:
      tags: [Files]
      operationId: deleteFile
      summary: Delete a file
      description: |
        Permanent. You may delete your OWN upload; deleting someone else's needs
        `delete_any_file`. Requires operation `files:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          file:
                            type: object
                            properties:
                              id: { type: integer }
                              name: { type: string }
              example:
                data:
                  deleted: true
                  file: { id: 108, name: الهوية-البصرية.pdf }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─────────────────────────── Folders ───────────────────────────
  /projects/{project_id}/folders:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Folders]
      operationId: createFolder
      summary: Create a folder
      description: |
        Creates a folder (an upload-level right, `manage_files`). Requires operation
        `files:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: [string, 'null'] }
                parent_folder_id: { type: [integer, 'null'], description: Parent folder; omit for the project root. }
            example:
              name: التصاميم
              description: ملفات الهوية وواجهات الموقع.
              parent_folder_id: null
      responses:
        '201':
          description: The created folder.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FolderEnvelope' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /projects/{project_id}/folders/{id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/FolderId'
    delete:
      tags: [Folders]
      operationId: deleteFolder
      summary: Delete a folder
      description: |
        Permanent, RECURSIVE — the folder and its entire subtree (every subfolder
        and file, whoever uploaded them). Demands `delete_any_file`, no creator
        bypass. Requires operation `files:destructive`.
      responses:
        '200':
          description: Deletion result.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/DeletedBase'
                      - type: object
                        properties:
                          folder:
                            type: object
                            properties:
                              id: { type: integer }
                              name: { type: string }
                          destroyed:
                            type: object
                            properties:
                              files: { type: integer }
                              subfolders: { type: integer }
              example:
                data:
                  deleted: true
                  folder: { id: 42, name: التصاميم }
                  destroyed: { files: 7, subfolders: 1 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        An ApiKey presented as a Bearer credential. Keys carry the `frq_api_`
        prefix and are shown once at creation (only their SHA-256 digest is stored).
        Isolated from the MCP server's Personal Access Tokens (`frq_`).


        A key belongs to the **company**, not to a person: it is administered from
        /settings/api_keys by the company owner or anyone granted the
        `manage_api_keys` permission, who can see and revoke every key the company
        has. Its capability is its own `allowed_operations` and its reach is the
        whole company; the person who created it is recorded as a signature and is
        not whose permissions the call runs with.


        So a key stops authenticating (401) for three reasons and no others:
        revocation, expiry, and the API being switched off for the company. It is
        explicitly unaffected by its creator changing role or leaving.

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size (default 50, max 100, clamped).
      schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
    Offset:
      name: offset
      in: query
      description: Number of records to skip (default 0).
      schema: { type: integer, default: 0, minimum: 0 }
    ProjectSlugId:
      name: id
      in: path
      required: true
      description: The project SLUG (unique within the company).
      schema: { type: string }
    ProjectId:
      name: project_id
      in: path
      required: true
      description: The project SLUG.
      schema: { type: string }
    TaskListSlugId:
      name: id
      in: path
      required: true
      description: The task-list SLUG (unique within its project).
      schema: { type: string }
    TaskListId:
      name: task_list_id
      in: path
      required: true
      description: The task-list SLUG.
      schema: { type: string }
    TaskSlugId:
      name: id
      in: path
      required: true
      description: The task SLUG (unique within its task list).
      schema: { type: string }
    TaskId:
      name: task_id
      in: path
      required: true
      description: The task SLUG.
      schema: { type: string }
    DiscussionSlugId:
      name: id
      in: path
      required: true
      description: The discussion SLUG (unique within its project).
      schema: { type: string }
    EventId:
      name: id
      in: path
      required: true
      description: The event's NUMERIC id (events have no slug).
      schema: { type: integer }
    FileId:
      name: id
      in: path
      required: true
      description: The file's NUMERIC id (files have no slug).
      schema: { type: integer }
    FolderId:
      name: id
      in: path
      required: true
      description: The folder's NUMERIC id (folders have no slug).
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unauthorized, message: Invalid or missing API key. } }
    Forbidden:
      description: >
        Two different refusals share this status, and a client must tell them apart
        by `error.code`. `forbidden` means the key's scope or allowlist does not
        permit this operation, or Pundit denied the action. `plan_upgrade_required`
        means the company's plan carries no API access at all, so no key on it can
        ever succeed and there is nothing to retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            forbidden:
              summary: This key may not perform this operation
              value: { error: { code: forbidden, message: This API key is not permitted to perform this operation. } }
            plan_upgrade_required:
              summary: The company's plan carries no API access
              value:
                error:
                  code: plan_upgrade_required
                  message: 'خطة «الاحترافي» لا تشمل الوصول إلى API. رقِّ إلى «المتطور» أو «الانتاجي» لتفعيله. — The الاحترافي plan does not include API access. Upgrade to «المتطور» or «الانتاجي» to enable it.'
    NotFound:
      description: >
        Resource not found or not accessible — also returned for EVERY endpoint when
        the company's `rest_api` feature flag is disabled (the surface is hidden).
        Lookups drill through the URL hierarchy, so another company's record is a
        404 and never a leak.

        A path that matches no route at all answers 404 with the distinct code
        `unknown_endpoint` and echoes the path back, so a mistyped or half-built URL
        is told apart from a record that is missing or out of reach.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: not_found, message: 'Resource not found, or you do not have access to it.' } }
    Unprocessable:
      description: A caller-fixable bad request (validation error, bad date, bad enum).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: unprocessable_entity, message: Title can't be blank } }
    Conflict:
      description: A uniqueness/record conflict; retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { error: { code: conflict, message: 'Could not complete due to a conflict; please retry.' } }
    RateLimited:
      description: >
        Either the company's daily API allowance is spent (`rate_limit_exceeded`),
        or the per-key / per-IP burst throttle of 300 requests per minute fired.
        Both come back after a wait, so `Retry-After` is honest here.


        **The two bodies are not the same shape.** The daily-quota refusal uses the
        standard error envelope. The burst throttle is served by Rack::Attack ahead
        of the application, so its body is a flat `{"error": "<string>"}` with no
        `code`. A client that reads `error.code` has to tolerate `error` being a
        plain string.
      headers:
        Retry-After:
          description: Seconds to wait before retrying. On a spent daily allowance
            this points at the company's next midnight.
          schema: { type: integer }
        X-RateLimit-Limit:
          description: The plan's daily API allowance, or `unlimited`.
          schema: { type: string }
        X-RateLimit-Remaining:
          description: Calls left in today's allowance, or `unlimited`.
          schema: { type: string }
        X-RateLimit-Reset:
          description: Unix timestamp of the next reset (the company's next midnight).
          schema: { type: integer }
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - type: object
                description: The Rack::Attack burst-throttle body. Flat, with no error code.
                required: [error]
                properties:
                  error: { type: string }
          examples:
            rate_limit_exceeded:
              summary: Today's daily allowance is spent (application envelope)
              value:
                error:
                  code: rate_limit_exceeded
                  message: 'استهلكت رصيد اليوم من طلبات API في خطة «المتطور» (1000 طلب يوميًا). يتجدد الرصيد عند منتصف الليل بتوقيت Asia/Riyadh. — Daily API quota exhausted: the المتطور plan allows 1000 calls per day. It resets at midnight Asia/Riyadh.'
            burst_throttled:
              summary: Over 300 requests in a minute (Rack::Attack body, flat error)
              value: { error: Rate limit exceeded. Please try again later. }

  schemas:

    # ── Error envelope ────────────────────────────────────────────
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: A stable machine-readable slug.
              enum: [unauthorized, forbidden, plan_upgrade_required, not_found, unprocessable_entity,
                     conflict, bad_request, rate_limit_exceeded]
            message:
              type: string
              description: A human-readable explanation (may join several validation messages with "; ").

    # ── Shared building blocks ────────────────────────────────────
    CollectionMeta:
      type: object
      properties:
        meta:
          type: object
          properties:
            total: { type: integer, description: The full unfiltered count. }
            limit: { type: integer }
            offset: { type: integer }
            count: { type: integer, description: Records in the returned page. }
      example:
        meta: { total: 4, limit: 50, offset: 0, count: 4 }

    DeletedBase:
      type: object
      required: [deleted]
      properties:
        deleted: { type: boolean, const: true }

    Priority:
      type: [string, 'null']
      description: Task-list priority.
      enum: [low, medium, high, urgent, null]

    ExternalSourceType:
      type: string
      description: The provider an external link points to (its domain is validated).
      enum: [notion, google_drive, dropbox, onedrive, box, figma, sketch, zoho, other]

    UserRef:
      type: object
      description: A user reference — name for humans, email as the write handle.
      properties:
        name: { type: string }
        email: { type: string, format: email }
      example: { name: عبدالله المطيري, email: abdullah@example.com }

    # ── Resource schemas (mirror Api::V1::Serializers) ────────────
    Account:
      type: object
      description: The calling ApiKey and the company it acts for (GET /me).
      properties:
        key:
          type: object
          properties:
            name: { type: string, description: The label the key was given in the app. }
            access:
              type: string
              enum: [read, write]
              description: The scope ceiling. A `write` key also carries `read`.
            operations:
              type: array
              items: { type: string }
              description: Exactly the operations this key may call. Anything else is a 403.
        company:
          type: object
          properties:
            name: { type: [string, 'null'] }
        created_by:
          type: object
          description: Who created the key. A signature, not the permissions the call runs with.
          properties:
            name: { type: [string, 'null'] }
            email: { type: [string, 'null'], format: email }
      example:
        key:
          name: مزامنة الفوترة
          access: write
          operations: [projects:read, tasks:read, tasks:write]
        company: { name: فريق التقنية }
        created_by: { name: سارة العتيبي, email: sara@example.com }

    Project:
      type: object
      properties:
        id: { type: string, description: The project slug (stable public id). }
        name: { type: string }
        description: { type: [string, 'null'] }
        is_public: { type: boolean }
        archived: { type: boolean }
        start_date: { type: [string, 'null'], format: date }
        end_date: { type: [string, 'null'], format: date }
        task_lists_count: { type: integer, description: Number of task lists (counter cache). }
        tasks_count: { type: integer, description: Number of tasks across the project's lists. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: تطوير-الموقع
        name: تطوير الموقع
        description: إعادة بناء الموقع التعريفي وربطه بالمنتج.
        is_public: false
        archived: false
        start_date: '2026-07-01'
        end_date: '2026-09-30'
        task_lists_count: 4
        tasks_count: 27
        created_at: '2026-07-01T09:15:00.000+03:00'
        updated_at: '2026-07-14T12:22:31.000+03:00'

    TaskList:
      type: object
      properties:
        id: { type: string, description: The task-list slug. }
        title: { type: string }
        notes: { type: [string, 'null'], description: Plain-text rendering of the rich-text notes. }
        priority: { $ref: '#/components/schemas/Priority' }
        completed: { type: boolean }
        completed_at: { type: [string, 'null'], format: date-time }
        tasks_count: { type: integer }
        completed_tasks_count: { type: integer }
        project_slug: { type: string }
        created_at: { type: string, format: date-time }
      example:
        id: الصفحة-الرئيسية
        title: الصفحة الرئيسية
        notes: نبدأ بالهيدر ثم قسم المزايا.
        priority: high
        completed: false
        completed_at: null
        tasks_count: 9
        completed_tasks_count: 3
        project_slug: تطوير-الموقع
        created_at: '2026-07-02T10:05:12.000+03:00'

    Task:
      type: object
      properties:
        id: { type: string, description: The task slug. }
        title: { type: string }
        notes: { type: [string, 'null'], description: Plain-text rendering of the rich-text notes. }
        completed: { type: boolean }
        completed_at: { type: [string, 'null'], format: date-time }
        due_at: { type: [string, 'null'], format: date, description: Due DATE (no time component). }
        project_slug: { type: [string, 'null'], description: Null for a personal task. }
        task_list_slug: { type: [string, 'null'], description: Null for a personal task. }
        assignee:
          oneOf:
            - $ref: '#/components/schemas/UserRef'
            - type: 'null'
        created_at: { type: string, format: date-time }
      example:
        id: تصميم-الهيدر
        title: تصميم الهيدر
        notes: نحتاج نسخة للجوال ونسخة للديسكتوب.
        completed: false
        completed_at: null
        due_at: '2026-08-10'
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        assignee: { name: عبدالله المطيري, email: abdullah@example.com }
        created_at: '2026-07-03T11:40:09.000+03:00'

    Discussion:
      type: object
      properties:
        id: { type: string, description: The discussion slug. }
        title: { type: string }
        body: { type: [string, 'null'], description: Plain-text rendering of the rich-text body. }
        category: { type: [string, 'null'] }
        author: { type: [string, 'null'] }
        replies_count: { type: integer }
        created_at: { type: string, format: date-time }
      example:
        id: اقتراح-لتحسين-صفحة
        title: اقتراح لتحسين صفحة التسعير
        body: أقترح نعرض الباقات في جدول مقارنة واحد بدل البطاقات المنفصلة.
        category: فكرة
        author: سارة العتيبي
        replies_count: 4
        created_at: '2026-07-08T14:02:55.000+03:00'

    Comment:
      type: object
      description: A comment (on a task list or task) or a majlis reply.
      properties:
        id: { type: integer, description: Numeric id (comments have no slug). }
        content: { type: [string, 'null'], description: Plain-text rendering of the rich-text content. }
        author: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
      example:
        id: 512
        content: راجعت النسخة الأخيرة، ينقصنا حالة الخطأ في النموذج.
        author: عبدالله المطيري
        created_at: '2026-07-15T09:48:03.000+03:00'

    ProjectFile:
      type: object
      properties:
        id: { type: integer, description: Numeric id (files have no slug). }
        name: { type: string }
        path: { type: string, description: The folder path the file lives at. }
        folder_id: { type: [integer, 'null'] }
        content_type: { type: [string, 'null'] }
        size_bytes: { type: [integer, 'null'] }
        size_human: { type: [string, 'null'] }
        external_url: { type: [string, 'null'], description: 'Set for an external link, null for an upload.' }
        source: { type: [string, 'null'], description: The source's display name. }
        scan_state: { type: string, description: 'Virus-scan state (e.g. pending, clean).' }
        downloadable: { type: boolean, description: True once the scan has cleared the file. }
        uploaded_by: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
      example:
        id: 108
        name: الهوية-البصرية.pdf
        path: التصاميم/الهوية-البصرية.pdf
        folder_id: 42
        content_type: application/pdf
        size_bytes: 2418176
        size_human: '2.31 م.بايت'
        external_url: null
        source: null
        scan_state: clean
        downloadable: true
        uploaded_by: سارة العتيبي
        created_at: '2026-07-10T16:31:44.000+03:00'

    Folder:
      type: object
      properties:
        id: { type: integer, description: Numeric id (folders have no slug). }
        name: { type: string }
        path: { type: string }
        description: { type: [string, 'null'] }
        parent_folder_id: { type: [integer, 'null'] }
        created_at: { type: string, format: date-time }
      example:
        id: 42
        name: التصاميم
        path: التصاميم
        description: ملفات الهوية وواجهات الموقع.
        parent_folder_id: null
        created_at: '2026-07-05T08:20:00.000+03:00'

    Event:
      type: object
      properties:
        id: { type: integer, description: Numeric id (events have no slug). }
        name: { type: string }
        description: { type: [string, 'null'] }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        all_day: { type: boolean }
        meeting_url: { type: [string, 'null'] }
        organizer: { type: [string, 'null'], description: Only the organizer may edit or delete. }
        organizer_email: { type: [string, 'null'] }
        attendees:
          type: array
          items: { $ref: '#/components/schemas/UserRef' }
      example:
        id: 77
        name: اجتماع مراجعة التصاميم
        description: نراجع نسخة الهيدر ونقرر الاتجاه النهائي.
        starts_at: '2026-08-05T13:00:00.000+03:00'
        ends_at: '2026-08-05T14:00:00.000+03:00'
        all_day: false
        meeting_url: https://meet.google.com/abc-defg-hij
        organizer: سارة العتيبي
        organizer_email: sara@example.com
        attendees:
          - { name: عبدالله المطيري, email: abdullah@example.com }

    Member:
      type: object
      description: An assignable person on a project.
      properties:
        name: { type: string }
        email: { type: string, format: email, description: The handle write endpoints take. }
        role: { type: string, description: The member's display role name. }
      example: { name: عبدالله المطيري, email: abdullah@example.com, role: عضو فريق }

    SearchResult:
      description: >
        A unified search hit. The shape depends on `type`; each carries the
        slugs/ids the other endpoints take.
      oneOf:
        - $ref: '#/components/schemas/SearchProject'
        - $ref: '#/components/schemas/SearchTaskList'
        - $ref: '#/components/schemas/SearchTask'
        - $ref: '#/components/schemas/SearchFolder'
        - $ref: '#/components/schemas/SearchFile'
        - $ref: '#/components/schemas/SearchDiscussion'
      discriminator:
        propertyName: type
        mapping:
          project: '#/components/schemas/SearchProject'
          task_list: '#/components/schemas/SearchTaskList'
          task: '#/components/schemas/SearchTask'
          folder: '#/components/schemas/SearchFolder'
          file: '#/components/schemas/SearchFile'
          discussion: '#/components/schemas/SearchDiscussion'

    SearchProject:
      type: object
      required: [type]
      properties:
        type: { type: string, const: project }
        title: { type: string }
        project_slug: { type: string }
        archived: { type: boolean }
      example: { type: project, title: تطوير الموقع, project_slug: تطوير-الموقع, archived: false }

    SearchTaskList:
      type: object
      required: [type]
      properties:
        type: { type: string, const: task_list }
        title: { type: string }
        project_slug: { type: string }
        task_list_slug: { type: string }
        completed: { type: boolean }
      example:
        type: task_list
        title: الصفحة الرئيسية
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        completed: false

    SearchTask:
      type: object
      required: [type]
      properties:
        type: { type: string, const: task }
        title: { type: string }
        project_slug: { type: string }
        task_list_slug: { type: string }
        task_slug: { type: string }
        completed: { type: boolean }
      example:
        type: task
        title: تصميم الهيدر
        project_slug: تطوير-الموقع
        task_list_slug: الصفحة-الرئيسية
        task_slug: تصميم-الهيدر
        completed: false

    SearchFolder:
      type: object
      required: [type]
      properties:
        type: { type: string, const: folder }
        name: { type: string }
        project_slug: { type: string }
        folder_id: { type: integer }
      example: { type: folder, name: التصاميم, project_slug: تطوير-الموقع, folder_id: 42 }

    SearchFile:
      type: object
      required: [type]
      properties:
        type: { type: string, const: file }
        name: { type: string }
        project_slug: { type: string }
        file_id: { type: integer }
        folder_id: { type: [integer, 'null'] }
      example:
        type: file
        name: الهوية-البصرية.pdf
        project_slug: تطوير-الموقع
        file_id: 108
        folder_id: 42

    SearchDiscussion:
      type: object
      required: [type]
      properties:
        type: { type: string, const: discussion }
        title: { type: string }
        project_slug: { type: string }
        discussion_slug: { type: string }
      example:
        type: discussion
        title: اقتراح لتحسين صفحة التسعير
        project_slug: تطوير-الموقع
        discussion_slug: اقتراح-لتحسين-صفحة

    # ── Single-resource envelopes ─────────────────────────────────
    AccountEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Account' }
    ProjectEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Project' }
    TaskListEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/TaskList' }
    TaskEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Task' }
    DiscussionEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Discussion' }
    CommentEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Comment' }
    ProjectFileEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/ProjectFile' }
    FolderEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Folder' }
    EventEnvelope:
      type: object
      properties:
        data: { $ref: '#/components/schemas/Event' }

    # ── Collection envelopes ──────────────────────────────────────
    TaskCollection:
      allOf:
        - $ref: '#/components/schemas/CollectionMeta'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Task' }
